diff --git a/aconfig/settings_connecteddevice_flag_declarations.aconfig b/aconfig/settings_connecteddevice_flag_declarations.aconfig
index 7942ccd1416..693e3985af8 100644
--- a/aconfig/settings_connecteddevice_flag_declarations.aconfig
+++ b/aconfig/settings_connecteddevice_flag_declarations.aconfig
@@ -8,6 +8,20 @@ flag {
bug: "299405720"
}
+flag {
+ name: "rotation_connected_display_setting"
+ namespace: "display_manager"
+ description: "Allow changing rotation of the connected display."
+ bug: "294015706"
+}
+
+flag {
+ name: "resolution_and_enable_connected_display_setting"
+ namespace: "display_manager"
+ description: "Allow enabling/disabling and changing resolution of the connected display."
+ bug: "253296253"
+}
+
flag {
name: "enable_auth_challenge_for_usb_preferences"
namespace: "safety_center"
@@ -15,7 +29,6 @@ flag {
bug: "317367746"
}
-
flag {
name: "enable_bonded_bluetooth_device_searchable"
namespace: "pixel_cross_device_control"
@@ -24,4 +37,4 @@ flag {
metadata {
purpose: PURPOSE_BUGFIX
}
-}
\ No newline at end of file
+}
diff --git a/res/drawable/external_display_mirror_landscape.xml b/res/drawable/external_display_mirror_landscape.xml
new file mode 100644
index 00000000000..4272ddbe7a8
--- /dev/null
+++ b/res/drawable/external_display_mirror_landscape.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/drawable/external_display_mirror_portrait.xml b/res/drawable/external_display_mirror_portrait.xml
new file mode 100644
index 00000000000..0fe7f93cdbc
--- /dev/null
+++ b/res/drawable/external_display_mirror_portrait.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/drawable/ic_external_display_32dp.xml b/res/drawable/ic_external_display_32dp.xml
new file mode 100644
index 00000000000..3e1828255d2
--- /dev/null
+++ b/res/drawable/ic_external_display_32dp.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/res/values/strings.xml b/res/values/strings.xml
index bfac793bc50..96d9d11b1f0 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1893,6 +1893,37 @@
Restart
+
+ mirror, external display, connected display, usb display, resolution, rotation
+
+ On
+
+ Off
+
+ External Display
+
+ Use external display
+
+ Display resolution
+
+ External display is disconnected
+
+ Rotation
+
+ Standard
+
+ 90°
+
+ 180°
+
+ 270°
+
+ Changing rotation or resolution may stop any apps that are currently running
+
+ Your device must be connected to an external display to mirror your screen
+
+ More options
+
Cast
@@ -7268,6 +7299,8 @@
+
+
diff --git a/res/xml/external_display_resolution_settings.xml b/res/xml/external_display_resolution_settings.xml
new file mode 100644
index 00000000000..6ac6b1ad52e
--- /dev/null
+++ b/res/xml/external_display_resolution_settings.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/res/xml/external_display_settings.xml b/res/xml/external_display_settings.xml
new file mode 100644
index 00000000000..00472115e0c
--- /dev/null
+++ b/res/xml/external_display_settings.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/src/com/android/settings/SettingsPreferenceFragmentBase.java b/src/com/android/settings/SettingsPreferenceFragmentBase.java
new file mode 100644
index 00000000000..dd2e28784ac
--- /dev/null
+++ b/src/com/android/settings/SettingsPreferenceFragmentBase.java
@@ -0,0 +1,94 @@
+/*
+ * 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;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settingslib.search.Indexable;
+
+/**
+ * Base class for fragment suitable for unit testing.
+ */
+public abstract class SettingsPreferenceFragmentBase extends SettingsPreferenceFragment
+ implements Indexable {
+ @Override
+ @SuppressWarnings({"RequiresNullabilityAnnotation"})
+ public void onCreate(final Bundle icicle) {
+ super.onCreate(icicle);
+ onCreateCallback(icicle);
+ }
+
+ @Override
+ @SuppressWarnings({"RequiresNullabilityAnnotation"})
+ public void onActivityCreated(final Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ onActivityCreatedCallback(savedInstanceState);
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull final Bundle outState) {
+ super.onSaveInstanceState(outState);
+ onSaveInstanceStateCallback(outState);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ onStartCallback();
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ onStopCallback();
+ }
+
+ protected Activity getCurrentActivity() {
+ return getActivity();
+ }
+
+ /**
+ * Callback called from {@link #onCreate}
+ */
+ public abstract void onCreateCallback(@Nullable Bundle icicle);
+
+ /**
+ * Callback called from {@link #onActivityCreated}
+ */
+ public abstract void onActivityCreatedCallback(@Nullable Bundle savedInstanceState);
+
+ /**
+ * Callback called from {@link #onStart}
+ */
+ public abstract void onStartCallback();
+
+ /**
+ * Callback called from {@link #onStop}
+ */
+ public abstract void onStopCallback();
+
+ /**
+ * Callback called from {@link #onSaveInstanceState}
+ */
+ public void onSaveInstanceStateCallback(@NonNull final Bundle outState) {
+ // Do nothing.
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/ConnectedDeviceGroupController.java b/src/com/android/settings/connecteddevice/ConnectedDeviceGroupController.java
index 56a3005f6dd..2548b95aac4 100644
--- a/src/com/android/settings/connecteddevice/ConnectedDeviceGroupController.java
+++ b/src/com/android/settings/connecteddevice/ConnectedDeviceGroupController.java
@@ -15,6 +15,8 @@
*/
package com.android.settings.connecteddevice;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.isExternalDisplaySettingsPageEnabled;
+
import android.content.Context;
import android.content.pm.PackageManager;
import android.hardware.input.InputManager;
@@ -22,6 +24,8 @@ import android.util.FeatureFlagUtils;
import android.util.Log;
import android.view.InputDevice;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.preference.Preference;
import androidx.preference.PreferenceGroup;
@@ -31,12 +35,15 @@ import com.android.settings.R;
import com.android.settings.bluetooth.BluetoothDeviceUpdater;
import com.android.settings.bluetooth.ConnectedBluetoothDeviceUpdater;
import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.display.ExternalDisplayUpdater;
import com.android.settings.connecteddevice.dock.DockUpdater;
import com.android.settings.connecteddevice.stylus.StylusDeviceUpdater;
import com.android.settings.connecteddevice.usb.ConnectedUsbDeviceUpdater;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.core.PreferenceControllerMixin;
import com.android.settings.dashboard.DashboardFragment;
+import com.android.settings.flags.FeatureFlags;
+import com.android.settings.flags.FeatureFlagsImpl;
import com.android.settings.flags.Flags;
import com.android.settings.overlay.DockUpdaterFeatureProvider;
import com.android.settings.overlay.FeatureFactory;
@@ -64,6 +71,8 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
@VisibleForTesting
PreferenceGroup mPreferenceGroup;
+ @Nullable
+ private ExternalDisplayUpdater mExternalDisplayUpdater;
private BluetoothDeviceUpdater mBluetoothDeviceUpdater;
private ConnectedUsbDeviceUpdater mConnectedUsbDeviceUpdater;
private DockUpdater mConnectedDockUpdater;
@@ -71,6 +80,8 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
private final PackageManager mPackageManager;
private final InputManager mInputManager;
private final LocalBluetoothManager mLocalBluetoothManager;
+ @NonNull
+ private final FeatureFlags mFeatureFlags = new FeatureFlagsImpl();
public ConnectedDeviceGroupController(Context context) {
super(context, KEY);
@@ -81,6 +92,10 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
@Override
public void onStart() {
+ if (mExternalDisplayUpdater != null) {
+ mExternalDisplayUpdater.registerCallback();
+ }
+
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.registerCallback();
mBluetoothDeviceUpdater.refreshPreference();
@@ -101,6 +116,10 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
@Override
public void onStop() {
+ if (mExternalDisplayUpdater != null) {
+ mExternalDisplayUpdater.unregisterCallback();
+ }
+
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.unregisterCallback();
}
@@ -127,6 +146,10 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
if (isAvailable()) {
final Context context = screen.getContext();
+ if (mExternalDisplayUpdater != null) {
+ mExternalDisplayUpdater.initPreference(context);
+ }
+
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.setPrefContext(context);
mBluetoothDeviceUpdater.forceUpdate();
@@ -150,7 +173,8 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
@Override
public int getAvailabilityStatus() {
- return (hasBluetoothFeature()
+ return (hasExternalDisplayFeature()
+ || hasBluetoothFeature()
|| hasUsbFeature()
|| hasUsiStylusFeature()
|| mConnectedDockUpdater != null)
@@ -180,11 +204,13 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
}
@VisibleForTesting
- void init(BluetoothDeviceUpdater bluetoothDeviceUpdater,
+ void init(@Nullable ExternalDisplayUpdater externalDisplayUpdater,
+ BluetoothDeviceUpdater bluetoothDeviceUpdater,
ConnectedUsbDeviceUpdater connectedUsbDeviceUpdater,
DockUpdater connectedDockUpdater,
StylusDeviceUpdater connectedStylusDeviceUpdater) {
+ mExternalDisplayUpdater = externalDisplayUpdater;
mBluetoothDeviceUpdater = bluetoothDeviceUpdater;
mConnectedUsbDeviceUpdater = connectedUsbDeviceUpdater;
mConnectedDockUpdater = connectedDockUpdater;
@@ -197,7 +223,10 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
FeatureFactory.getFeatureFactory().getDockUpdaterFeatureProvider();
final DockUpdater connectedDockUpdater =
dockUpdaterFeatureProvider.getConnectedDockUpdater(context, this);
- init(hasBluetoothFeature()
+ init(hasExternalDisplayFeature()
+ ? new ExternalDisplayUpdater(this, fragment.getMetricsCategory())
+ : null,
+ hasBluetoothFeature()
? new ConnectedBluetoothDeviceUpdater(context, this,
fragment.getMetricsCategory())
: null,
@@ -210,6 +239,19 @@ public class ConnectedDeviceGroupController extends BasePreferenceController
: null);
}
+ /**
+ * @return trunk stable feature flags.
+ */
+ @VisibleForTesting
+ @NonNull
+ public FeatureFlags getFeatureFlags() {
+ return mFeatureFlags;
+ }
+
+ private boolean hasExternalDisplayFeature() {
+ return isExternalDisplaySettingsPageEnabled(getFeatureFlags());
+ }
+
private boolean hasBluetoothFeature() {
return mPackageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH);
}
diff --git a/src/com/android/settings/connecteddevice/display/ExternalDisplayPreferenceFragment.java b/src/com/android/settings/connecteddevice/display/ExternalDisplayPreferenceFragment.java
new file mode 100644
index 00000000000..09f8e92ea49
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/display/ExternalDisplayPreferenceFragment.java
@@ -0,0 +1,544 @@
+/*
+ * 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.display;
+
+
+import static android.view.Display.INVALID_DISPLAY;
+
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.EXTERNAL_DISPLAY_HELP_URL;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.DISPLAY_ID_ARG;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.EXTERNAL_DISPLAY_NOT_FOUND_RESOURCE;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.isDisplayAllowed;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.isUseDisplaySettingEnabled;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.isResolutionSettingEnabled;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.isRotationSettingEnabled;
+
+import android.app.Activity;
+import android.app.settings.SettingsEnums;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.Display;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceScreen;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.settings.R;
+import com.android.settings.SettingsPreferenceFragmentBase;
+import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.DisplayListener;
+import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.Injector;
+import com.android.settings.core.SubSettingLauncher;
+import com.android.settings.search.BaseSearchIndexProvider;
+import com.android.settingslib.search.Indexable;
+import com.android.settingslib.search.SearchIndexable;
+import com.android.settingslib.widget.FooterPreference;
+import com.android.settingslib.widget.IllustrationPreference;
+import com.android.settingslib.widget.MainSwitchPreference;
+import com.android.settingslib.widget.TwoTargetPreference;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The Settings screen for External Displays configuration and connection management.
+ */
+@SearchIndexable(forTarget = SearchIndexable.ALL & ~SearchIndexable.ARC)
+public class ExternalDisplayPreferenceFragment extends SettingsPreferenceFragmentBase
+ implements Indexable {
+ static final int EXTERNAL_DISPLAY_SETTINGS_RESOURCE = R.xml.external_display_settings;
+ public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
+ new BaseSearchIndexProvider(EXTERNAL_DISPLAY_SETTINGS_RESOURCE);
+ static final String DISPLAYS_LIST_PREFERENCE_KEY = "displays_list_preference";
+ static final String EXTERNAL_DISPLAY_USE_PREFERENCE_KEY = "external_display_use_preference";
+ static final String EXTERNAL_DISPLAY_ROTATION_KEY = "external_display_rotation";
+ static final String EXTERNAL_DISPLAY_RESOLUTION_PREFERENCE_KEY = "external_display_resolution";
+ static final int EXTERNAL_DISPLAY_CHANGE_RESOLUTION_FOOTER_RESOURCE =
+ R.string.external_display_change_resolution_footer_title;
+ static final int EXTERNAL_DISPLAY_LANDSCAPE_DRAWABLE =
+ R.drawable.external_display_mirror_landscape;
+ static final int EXTERANAL_DISPLAY_TITLE_RESOURCE =
+ R.string.external_display_settings_title;
+ static final int EXTERNAL_DISPLAY_USE_TITLE_RESOURCE =
+ R.string.external_display_use_title;
+ static final int EXTERNAL_DISPLAY_NOT_FOUND_FOOTER_RESOURCE =
+ R.string.external_display_not_found_footer_title;
+ static final int EXTERNAL_DISPLAY_PORTRAIT_DRAWABLE =
+ R.drawable.external_display_mirror_portrait;
+ static final int EXTERNAL_DISPLAY_ROTATION_TITLE_RESOURCE =
+ R.string.external_display_rotation;
+ static final int EXTERNAL_DISPLAY_RESOLUTION_TITLE_RESOURCE =
+ R.string.external_display_resolution_settings_title;
+ @VisibleForTesting
+ static final String PREVIOUSLY_SHOWN_LIST_KEY = "mPreviouslyShownListOfDisplays";
+ private boolean mStarted;
+ @Nullable
+ private MainSwitchPreference mUseDisplayPref;
+ @Nullable
+ private IllustrationPreference mImagePreference;
+ @Nullable
+ private Preference mResolutionPreference;
+ @Nullable
+ private ListPreference mRotationPref;
+ @Nullable
+ private FooterPreference mFooterPreference;
+ @Nullable
+ private PreferenceCategory mDisplaysPreference;
+ @Nullable
+ private Injector mInjector;
+ @Nullable
+ private String[] mRotationEntries;
+ @Nullable
+ private String[] mRotationEntriesValues;
+ @NonNull
+ private final Runnable mUpdateRunnable = this::update;
+ private final DisplayListener mListener = new DisplayListener() {
+ @Override
+ public void update(int displayId) {
+ scheduleUpdate();
+ }
+ };
+ private boolean mPreviouslyShownListOfDisplays;
+
+ public ExternalDisplayPreferenceFragment() {}
+
+ @VisibleForTesting
+ ExternalDisplayPreferenceFragment(@NonNull Injector injector) {
+ mInjector = injector;
+ }
+
+ @Override
+ public int getMetricsCategory() {
+ return SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY;
+ }
+
+ @Override
+ public int getHelpResource() {
+ return EXTERNAL_DISPLAY_HELP_URL;
+ }
+
+ @Override
+ public void onSaveInstanceStateCallback(@NonNull Bundle outState) {
+ outState.putSerializable(PREVIOUSLY_SHOWN_LIST_KEY,
+ (Serializable) mPreviouslyShownListOfDisplays);
+ }
+
+ @Override
+ public void onCreateCallback(@Nullable Bundle icicle) {
+ if (mInjector == null) {
+ mInjector = new Injector(getPrefContext());
+ }
+ addPreferencesFromResource(EXTERNAL_DISPLAY_SETTINGS_RESOURCE);
+ }
+
+ @Override
+ public void onActivityCreatedCallback(@Nullable Bundle savedInstanceState) {
+ restoreState(savedInstanceState);
+ View view = getView();
+ TextView emptyView = null;
+ if (view != null) {
+ emptyView = (TextView) view.findViewById(android.R.id.empty);
+ }
+ if (emptyView != null) {
+ emptyView.setText(EXTERNAL_DISPLAY_NOT_FOUND_RESOURCE);
+ setEmptyView(emptyView);
+ }
+ }
+
+ @Override
+ public void onStartCallback() {
+ mStarted = true;
+ if (mInjector == null) {
+ return;
+ }
+ mInjector.registerDisplayListener(mListener);
+ scheduleUpdate();
+ }
+
+ @Override
+ public void onStopCallback() {
+ mStarted = false;
+ if (mInjector == null) {
+ return;
+ }
+ mInjector.unregisterDisplayListener(mListener);
+ unscheduleUpdate();
+ }
+
+ /**
+ * @return id of the preference.
+ */
+ @Override
+ protected int getPreferenceScreenResId() {
+ return EXTERNAL_DISPLAY_SETTINGS_RESOURCE;
+ }
+
+ @VisibleForTesting
+ protected void launchResolutionSelector(@NonNull final Context context, final int displayId) {
+ final Bundle args = new Bundle();
+ args.putInt(DISPLAY_ID_ARG, displayId);
+ new SubSettingLauncher(context)
+ .setDestination(ResolutionPreferenceFragment.class.getName())
+ .setArguments(args)
+ .setSourceMetricsCategory(getMetricsCategory()).launch();
+ }
+
+ @VisibleForTesting
+ protected void launchDisplaySettings(final int displayId) {
+ final Bundle args = new Bundle();
+ var context = getPrefContext();
+ args.putInt(DISPLAY_ID_ARG, displayId);
+ new SubSettingLauncher(context)
+ .setDestination(this.getClass().getName())
+ .setArguments(args)
+ .setSourceMetricsCategory(getMetricsCategory()).launch();
+ }
+
+ /**
+ * Returns the preference for the footer.
+ */
+ @NonNull
+ @VisibleForTesting
+ FooterPreference getFooterPreference(@NonNull Context context) {
+ if (mFooterPreference == null) {
+ mFooterPreference = new FooterPreference(context);
+ mFooterPreference.setPersistent(false);
+ }
+ return mFooterPreference;
+ }
+
+ @NonNull
+ @VisibleForTesting
+ ListPreference getRotationPreference(@NonNull Context context) {
+ if (mRotationPref == null) {
+ mRotationPref = new ListPreference(context);
+ mRotationPref.setPersistent(false);
+ }
+ return mRotationPref;
+ }
+
+ @NonNull
+ @VisibleForTesting
+ Preference getResolutionPreference(@NonNull Context context) {
+ if (mResolutionPreference == null) {
+ mResolutionPreference = new Preference(context);
+ mResolutionPreference.setPersistent(false);
+ }
+ return mResolutionPreference;
+ }
+
+ @NonNull
+ @VisibleForTesting
+ MainSwitchPreference getUseDisplayPreference(@NonNull Context context) {
+ if (mUseDisplayPref == null) {
+ mUseDisplayPref = new MainSwitchPreference(context);
+ mUseDisplayPref.setPersistent(false);
+ }
+ return mUseDisplayPref;
+ }
+
+ @NonNull
+ @VisibleForTesting
+ IllustrationPreference getIllustrationPreference(@NonNull Context context) {
+ if (mImagePreference == null) {
+ mImagePreference = new IllustrationPreference(context);
+ mImagePreference.setPersistent(false);
+ }
+ return mImagePreference;
+ }
+
+ /**
+ * @return return display id argument of this settings page.
+ */
+ @VisibleForTesting
+ protected int getDisplayIdArg() {
+ var args = getArguments();
+ return args != null ? args.getInt(DISPLAY_ID_ARG, INVALID_DISPLAY) : INVALID_DISPLAY;
+ }
+
+ @NonNull
+ private PreferenceCategory getDisplaysListPreference(@NonNull Context context) {
+ if (mDisplaysPreference == null) {
+ mDisplaysPreference = new PreferenceCategory(context);
+ mDisplaysPreference.setPersistent(false);
+ }
+ return mDisplaysPreference;
+ }
+
+ private void restoreState(@Nullable Bundle savedInstanceState) {
+ if (savedInstanceState == null) {
+ return;
+ }
+ mPreviouslyShownListOfDisplays = Boolean.TRUE.equals(savedInstanceState.getSerializable(
+ PREVIOUSLY_SHOWN_LIST_KEY, Boolean.class));
+ }
+
+ private void update() {
+ final var screen = getPreferenceScreen();
+ if (screen == null || mInjector == null || mInjector.getContext() == null) {
+ return;
+ }
+ screen.removeAll();
+ updateScreenForDisplayId(getDisplayIdArg(), screen, mInjector.getContext());
+ }
+
+ private void updateScreenForDisplayId(final int displayId,
+ @NonNull final PreferenceScreen screen, @NonNull Context context) {
+ final var displaysToShow = getDisplaysToShow(displayId);
+ if (displaysToShow.isEmpty() && displayId == INVALID_DISPLAY) {
+ showTextWhenNoDisplaysToShow(screen, context);
+ } else if (displaysToShow.size() == 1
+ && ((displayId == INVALID_DISPLAY && !mPreviouslyShownListOfDisplays)
+ || displaysToShow.get(0).getDisplayId() == displayId)) {
+ showDisplaySettings(displaysToShow.get(0), screen, context);
+ } else if (displayId == INVALID_DISPLAY) {
+ // If ever shown a list of displays - keep showing it for consistency after
+ // disconnecting one of the displays, and only one display is left.
+ mPreviouslyShownListOfDisplays = true;
+ showDisplaysList(displaysToShow, screen, context);
+ }
+ updateSettingsTitle(displaysToShow, displayId);
+ }
+
+ private void updateSettingsTitle(@NonNull final List displaysToShow, int displayId) {
+ final Activity activity = getCurrentActivity();
+ if (activity == null) {
+ return;
+ }
+ if (displaysToShow.size() == 1 && displaysToShow.get(0).getDisplayId() == displayId) {
+ var displayName = displaysToShow.get(0).getName();
+ if (!displayName.isEmpty()) {
+ activity.setTitle(displayName.substring(0, Math.min(displayName.length(), 40)));
+ return;
+ }
+ }
+ activity.setTitle(EXTERANAL_DISPLAY_TITLE_RESOURCE);
+ }
+
+ private void showTextWhenNoDisplaysToShow(@NonNull final PreferenceScreen screen,
+ @NonNull Context context) {
+ if (isUseDisplaySettingEnabled(mInjector)) {
+ screen.addPreference(updateUseDisplayPreferenceNoDisplaysFound(context));
+ }
+ screen.addPreference(updateFooterPreference(context,
+ EXTERNAL_DISPLAY_NOT_FOUND_FOOTER_RESOURCE));
+ }
+
+ private void showDisplaySettings(@NonNull Display display, @NonNull PreferenceScreen screen,
+ @NonNull Context context) {
+ final var isEnabled = mInjector != null && mInjector.isDisplayEnabled(display);
+ if (isUseDisplaySettingEnabled(mInjector)) {
+ screen.addPreference(updateUseDisplayPreference(context, display, isEnabled));
+ }
+ if (!isEnabled) {
+ // Skip all other settings
+ return;
+ }
+ final var displayRotation = getDisplayRotation(display.getDisplayId());
+ screen.addPreference(updateIllustrationImage(context, displayRotation));
+ screen.addPreference(updateResolutionPreference(context, display));
+ screen.addPreference(updateRotationPreference(context, display, displayRotation));
+ if (isResolutionSettingEnabled(mInjector)) {
+ screen.addPreference(updateFooterPreference(context,
+ EXTERNAL_DISPLAY_CHANGE_RESOLUTION_FOOTER_RESOURCE));
+ }
+ }
+
+ private void showDisplaysList(@NonNull List displaysToShow,
+ @NonNull PreferenceScreen screen, @NonNull Context context) {
+ var pref = getDisplaysListPreference(context);
+ pref.setKey(DISPLAYS_LIST_PREFERENCE_KEY);
+ pref.removeAll();
+ if (!displaysToShow.isEmpty()) {
+ screen.addPreference(pref);
+ }
+ for (var display : displaysToShow) {
+ pref.addPreference(new DisplayPreference(context, display));
+ }
+ }
+
+ private List getDisplaysToShow(int displayIdToShow) {
+ if (mInjector == null) {
+ return List.of();
+ }
+ if (displayIdToShow != INVALID_DISPLAY) {
+ var display = mInjector.getDisplay(displayIdToShow);
+ if (display != null && isDisplayAllowed(display, mInjector)) {
+ return List.of(display);
+ }
+ }
+ var displaysToShow = new ArrayList();
+ for (var display : mInjector.getAllDisplays()) {
+ if (display != null && isDisplayAllowed(display, mInjector)) {
+ displaysToShow.add(display);
+ }
+ }
+ return displaysToShow;
+ }
+
+ private Preference updateUseDisplayPreferenceNoDisplaysFound(@NonNull Context context) {
+ final var pref = getUseDisplayPreference(context);
+ pref.setKey(EXTERNAL_DISPLAY_USE_PREFERENCE_KEY);
+ pref.setTitle(EXTERNAL_DISPLAY_USE_TITLE_RESOURCE);
+ pref.setChecked(false);
+ pref.setEnabled(false);
+ pref.setOnPreferenceChangeListener(null);
+ return pref;
+ }
+
+ private Preference updateUseDisplayPreference(@NonNull final Context context,
+ @NonNull final Display display, boolean isEnabled) {
+ final var pref = getUseDisplayPreference(context);
+ pref.setKey(EXTERNAL_DISPLAY_USE_PREFERENCE_KEY);
+ pref.setTitle(EXTERNAL_DISPLAY_USE_TITLE_RESOURCE);
+ pref.setChecked(isEnabled);
+ pref.setEnabled(true);
+ pref.setOnPreferenceChangeListener((p, newValue) -> {
+ writePreferenceClickMetric(p);
+ final boolean result;
+ if (mInjector == null) {
+ return false;
+ }
+ if ((Boolean) newValue) {
+ result = mInjector.enableConnectedDisplay(display.getDisplayId());
+ } else {
+ result = mInjector.disableConnectedDisplay(display.getDisplayId());
+ }
+ if (result) {
+ pref.setChecked((Boolean) newValue);
+ }
+ return result;
+ });
+ return pref;
+ }
+
+ private Preference updateIllustrationImage(@NonNull final Context context,
+ final int displayRotation) {
+ var pref = getIllustrationPreference(context);
+ if (displayRotation % 2 == 0) {
+ pref.setLottieAnimationResId(EXTERNAL_DISPLAY_PORTRAIT_DRAWABLE);
+ } else {
+ pref.setLottieAnimationResId(EXTERNAL_DISPLAY_LANDSCAPE_DRAWABLE);
+ }
+ return pref;
+ }
+
+ private Preference updateFooterPreference(@NonNull final Context context, final int title) {
+ var pref = getFooterPreference(context);
+ pref.setTitle(title);
+ return pref;
+ }
+
+ private Preference updateRotationPreference(@NonNull final Context context,
+ @NonNull final Display display, final int displayRotation) {
+ var pref = getRotationPreference(context);
+ pref.setKey(EXTERNAL_DISPLAY_ROTATION_KEY);
+ pref.setTitle(EXTERNAL_DISPLAY_ROTATION_TITLE_RESOURCE);
+ if (mRotationEntries == null || mRotationEntriesValues == null) {
+ mRotationEntries = new String[] {
+ context.getString(R.string.external_display_standard_rotation),
+ context.getString(R.string.external_display_rotation_90),
+ context.getString(R.string.external_display_rotation_180),
+ context.getString(R.string.external_display_rotation_270)};
+ mRotationEntriesValues = new String[] {"0", "1", "2", "3"};
+ }
+ pref.setEntries(mRotationEntries);
+ pref.setEntryValues(mRotationEntriesValues);
+ pref.setValueIndex(displayRotation);
+ pref.setSummary(mRotationEntries[displayRotation]);
+ pref.setOnPreferenceChangeListener((p, newValue) -> {
+ writePreferenceClickMetric(p);
+ var rotation = Integer.parseInt((String) newValue);
+ var displayId = display.getDisplayId();
+ if (mInjector == null || !mInjector.freezeDisplayRotation(displayId, rotation)) {
+ return false;
+ }
+ pref.setValueIndex(rotation);
+ return true;
+ });
+ pref.setEnabled(isRotationSettingEnabled(mInjector));
+ return pref;
+ }
+
+ private Preference updateResolutionPreference(@NonNull final Context context,
+ @NonNull final Display display) {
+ var pref = getResolutionPreference(context);
+ pref.setKey(EXTERNAL_DISPLAY_RESOLUTION_PREFERENCE_KEY);
+ pref.setTitle(EXTERNAL_DISPLAY_RESOLUTION_TITLE_RESOURCE);
+ pref.setSummary(display.getMode().getPhysicalWidth() + " x "
+ + display.getMode().getPhysicalHeight());
+ pref.setOnPreferenceClickListener((Preference p) -> {
+ writePreferenceClickMetric(p);
+ launchResolutionSelector(context, display.getDisplayId());
+ return true;
+ });
+ pref.setEnabled(isResolutionSettingEnabled(mInjector));
+ return pref;
+ }
+
+ private int getDisplayRotation(int displayId) {
+ if (mInjector == null) {
+ return 0;
+ }
+ return Math.min(3, Math.max(0, mInjector.getDisplayUserRotation(displayId)));
+ }
+
+ private void scheduleUpdate() {
+ if (mInjector == null || !mStarted) {
+ return;
+ }
+ unscheduleUpdate();
+ mInjector.getHandler().post(mUpdateRunnable);
+ }
+
+ private void unscheduleUpdate() {
+ if (mInjector == null || !mStarted) {
+ return;
+ }
+ mInjector.getHandler().removeCallbacks(mUpdateRunnable);
+ }
+
+ @VisibleForTesting
+ class DisplayPreference extends TwoTargetPreference
+ implements Preference.OnPreferenceClickListener {
+ private final int mDisplayId;
+
+ DisplayPreference(@NonNull final Context context, @NonNull final Display display) {
+ super(context);
+ mDisplayId = display.getDisplayId();
+ setPersistent(false);
+ setKey("display_id_" + mDisplayId);
+ setTitle(display.getName());
+ setSummary(display.getMode().getPhysicalWidth() + " x "
+ + display.getMode().getPhysicalHeight());
+ setOnPreferenceClickListener(this);
+ }
+
+ @Override
+ public boolean onPreferenceClick(@NonNull Preference preference) {
+ launchDisplaySettings(mDisplayId);
+ writePreferenceClickMetric(preference);
+ return true;
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/display/ExternalDisplaySettingsConfiguration.java b/src/com/android/settings/connecteddevice/display/ExternalDisplaySettingsConfiguration.java
new file mode 100644
index 00000000000..89d464c9a4e
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/display/ExternalDisplaySettingsConfiguration.java
@@ -0,0 +1,341 @@
+/*
+ * 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.display;
+
+import static android.content.Context.DISPLAY_SERVICE;
+import static android.hardware.display.DisplayManager.DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED;
+import static android.hardware.display.DisplayManager.EVENT_FLAG_DISPLAY_ADDED;
+import static android.hardware.display.DisplayManager.EVENT_FLAG_DISPLAY_CHANGED;
+import static android.hardware.display.DisplayManager.EVENT_FLAG_DISPLAY_CONNECTION_CHANGED;
+import static android.hardware.display.DisplayManager.EVENT_FLAG_DISPLAY_REMOVED;
+import static android.view.Display.INVALID_DISPLAY;
+
+import android.content.Context;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.DisplayManagerGlobal;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.SystemProperties;
+import android.view.Display;
+import android.view.Display.Mode;
+import android.view.IWindowManager;
+import android.view.WindowManagerGlobal;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settings.R;
+import com.android.settings.flags.FeatureFlags;
+import com.android.settings.flags.FeatureFlagsImpl;
+
+public class ExternalDisplaySettingsConfiguration {
+ static final String VIRTUAL_DISPLAY_PACKAGE_NAME_SYSTEM_PROPERTY =
+ "persist.demo.userrotation.package_name";
+ static final String DISPLAY_ID_ARG = "display_id";
+ static final int EXTERNAL_DISPLAY_NOT_FOUND_RESOURCE = R.string.external_display_not_found;
+ static final int EXTERNAL_DISPLAY_HELP_URL = R.string.help_url_external_display;
+
+ public static class SystemServicesProvider {
+ @Nullable
+ private IWindowManager mWindowManager;
+ @Nullable
+ private DisplayManager mDisplayManager;
+ @Nullable
+ protected Context mContext;
+ /**
+ * @param name of a system property.
+ * @return the value of the system property.
+ */
+ @NonNull
+ public String getSystemProperty(@NonNull String name) {
+ return SystemProperties.get(name);
+ }
+
+ /**
+ * @return return public Display manager.
+ */
+ @Nullable
+ public DisplayManager getDisplayManager() {
+ if (mDisplayManager == null && getContext() != null) {
+ mDisplayManager = (DisplayManager) getContext().getSystemService(DISPLAY_SERVICE);
+ }
+ return mDisplayManager;
+ }
+
+ /**
+ * @return internal IWindowManager
+ */
+ @Nullable
+ public IWindowManager getWindowManager() {
+ if (mWindowManager == null) {
+ mWindowManager = WindowManagerGlobal.getWindowManagerService();
+ }
+ return mWindowManager;
+ }
+
+ /**
+ * @return context.
+ */
+ @Nullable
+ public Context getContext() {
+ return mContext;
+ }
+ }
+
+ public static class Injector extends SystemServicesProvider {
+ @NonNull
+ private final FeatureFlags mFlags;
+ @NonNull
+ private final Handler mHandler;
+
+ Injector(@Nullable Context context) {
+ this(context, new FeatureFlagsImpl(), new Handler(Looper.getMainLooper()));
+ }
+
+ Injector(@Nullable Context context, @NonNull FeatureFlags flags, @NonNull Handler handler) {
+ mContext = context;
+ mFlags = flags;
+ mHandler = handler;
+ }
+
+ /**
+ * @return all displays including disabled.
+ */
+ @NonNull
+ public Display[] getAllDisplays() {
+ var dm = getDisplayManager();
+ if (dm == null) {
+ return new Display[0];
+ }
+ return dm.getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED);
+ }
+
+ /**
+ * @return enabled displays only.
+ */
+ @NonNull
+ public Display[] getEnabledDisplays() {
+ var dm = getDisplayManager();
+ if (dm == null) {
+ return new Display[0];
+ }
+ return dm.getDisplays();
+ }
+
+ /**
+ * @return true if the display is enabled
+ */
+ public boolean isDisplayEnabled(@NonNull Display display) {
+ for (var enabledDisplay : getEnabledDisplays()) {
+ if (enabledDisplay.getDisplayId() == display.getDisplayId()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Register display listener.
+ */
+ public void registerDisplayListener(@NonNull DisplayManager.DisplayListener listener) {
+ var dm = getDisplayManager();
+ if (dm == null) {
+ return;
+ }
+ dm.registerDisplayListener(listener, mHandler, EVENT_FLAG_DISPLAY_ADDED
+ | EVENT_FLAG_DISPLAY_CHANGED | EVENT_FLAG_DISPLAY_REMOVED
+ | EVENT_FLAG_DISPLAY_CONNECTION_CHANGED);
+ }
+
+ /**
+ * Unregister display listener.
+ */
+ public void unregisterDisplayListener(@NonNull DisplayManager.DisplayListener listener) {
+ var dm = getDisplayManager();
+ if (dm == null) {
+ return;
+ }
+ dm.unregisterDisplayListener(listener);
+ }
+
+ /**
+ * @return feature flags.
+ */
+ @NonNull
+ public FeatureFlags getFlags() {
+ return mFlags;
+ }
+
+ /**
+ * Enable connected display.
+ */
+ public boolean enableConnectedDisplay(int displayId) {
+ var dm = getDisplayManager();
+ if (dm == null) {
+ return false;
+ }
+ dm.enableConnectedDisplay(displayId);
+ return true;
+ }
+
+ /**
+ * Disable connected display.
+ */
+ public boolean disableConnectedDisplay(int displayId) {
+ var dm = getDisplayManager();
+ if (dm == null) {
+ return false;
+ }
+ dm.disableConnectedDisplay(displayId);
+ return true;
+ }
+
+ /**
+ * @param displayId which must be returned
+ * @return display object for the displayId
+ */
+ @Nullable
+ public Display getDisplay(int displayId) {
+ if (displayId == INVALID_DISPLAY) {
+ return null;
+ }
+ var dm = getDisplayManager();
+ if (dm == null) {
+ return null;
+ }
+ return dm.getDisplay(displayId);
+ }
+
+ /**
+ * @return handler
+ */
+ @NonNull
+ public Handler getHandler() {
+ return mHandler;
+ }
+
+ /**
+ * Get display rotation
+ * @param displayId display identifier
+ * @return rotation
+ */
+ public int getDisplayUserRotation(int displayId) {
+ var wm = getWindowManager();
+ if (wm == null) {
+ return 0;
+ }
+ try {
+ return wm.getDisplayUserRotation(displayId);
+ } catch (RemoteException e) {
+ return 0;
+ }
+ }
+
+ /**
+ * Freeze rotation of the display in the specified rotation.
+ * @param displayId display identifier
+ * @param rotation [0, 1, 2, 3]
+ * @return true if successful
+ */
+ public boolean freezeDisplayRotation(int displayId, int rotation) {
+ var wm = getWindowManager();
+ if (wm == null) {
+ return false;
+ }
+ try {
+ wm.freezeDisplayRotation(displayId, rotation,
+ "ExternalDisplayPreferenceFragment");
+ return true;
+ } catch (RemoteException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Enforce display mode on the given display.
+ */
+ public void setUserPreferredDisplayMode(int displayId, @NonNull Mode mode) {
+ DisplayManagerGlobal.getInstance().setUserPreferredDisplayMode(displayId, mode);
+ }
+ }
+
+ public abstract static class DisplayListener implements DisplayManager.DisplayListener {
+ @Override
+ public void onDisplayAdded(int displayId) {
+ update(displayId);
+ }
+
+ @Override
+ public void onDisplayRemoved(int displayId) {
+ update(displayId);
+ }
+
+ @Override
+ public void onDisplayChanged(int displayId) {
+ update(displayId);
+ }
+
+ @Override
+ public void onDisplayConnected(int displayId) {
+ update(displayId);
+ }
+
+ @Override
+ public void onDisplayDisconnected(int displayId) {
+ update(displayId);
+ }
+
+ /**
+ * Called from other listener methods to trigger update of the settings page.
+ */
+ public abstract void update(int displayId);
+ }
+
+ /**
+ * @return whether the settings page is enabled or not.
+ */
+ public static boolean isExternalDisplaySettingsPageEnabled(@NonNull FeatureFlags flags) {
+ return flags.rotationConnectedDisplaySetting()
+ || flags.resolutionAndEnableConnectedDisplaySetting();
+ }
+
+ static boolean isDisplayAllowed(@NonNull Display display,
+ @NonNull SystemServicesProvider props) {
+ return display.getType() == Display.TYPE_EXTERNAL
+ || display.getType() == Display.TYPE_OVERLAY
+ || isVirtualDisplayAllowed(display, props);
+ }
+
+ static boolean isVirtualDisplayAllowed(@NonNull Display display,
+ @NonNull SystemServicesProvider properties) {
+ var sysProp = properties.getSystemProperty(VIRTUAL_DISPLAY_PACKAGE_NAME_SYSTEM_PROPERTY);
+ return !sysProp.isEmpty() && display.getType() == Display.TYPE_VIRTUAL
+ && sysProp.equals(display.getOwnerPackageName());
+ }
+
+ static boolean isUseDisplaySettingEnabled(@Nullable Injector injector) {
+ return injector != null && injector.getFlags().resolutionAndEnableConnectedDisplaySetting();
+ }
+
+ static boolean isResolutionSettingEnabled(@Nullable Injector injector) {
+ return injector != null && injector.getFlags().resolutionAndEnableConnectedDisplaySetting();
+ }
+
+ static boolean isRotationSettingEnabled(@Nullable Injector injector) {
+ return injector != null && injector.getFlags().rotationConnectedDisplaySetting();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/display/ExternalDisplayUpdater.java b/src/com/android/settings/connecteddevice/display/ExternalDisplayUpdater.java
new file mode 100644
index 00000000000..64dd7bb2fdf
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/display/ExternalDisplayUpdater.java
@@ -0,0 +1,181 @@
+/*
+ * 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.display;
+
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.isDisplayAllowed;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.UserHandle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.preference.Preference;
+
+import com.android.settings.R;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.DisplayListener;
+import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.Injector;
+import com.android.settings.core.SubSettingLauncher;
+import com.android.settings.overlay.FeatureFactory;
+import com.android.settingslib.RestrictedLockUtils;
+import com.android.settingslib.RestrictedLockUtilsInternal;
+import com.android.settingslib.RestrictedPreference;
+import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
+
+public class ExternalDisplayUpdater {
+
+ private static final String PREF_KEY = "external_display_settings";
+ private final int mMetricsCategory;
+ @NonNull
+ private final MetricsFeatureProvider mMetricsFeatureProvider;
+ @NonNull
+ private final Runnable mUpdateRunnable = this::update;
+ @NonNull
+ private final DevicePreferenceCallback mDevicePreferenceCallback;
+ @Nullable
+ private RestrictedPreference mPreference;
+ @Nullable
+ private Injector mInjector;
+ private final DisplayListener mListener = new DisplayListener() {
+ @Override
+ public void update(int displayId) {
+ scheduleUpdate();
+ }
+ };
+
+ public ExternalDisplayUpdater(@NonNull DevicePreferenceCallback callback, int metricsCategory) {
+ mDevicePreferenceCallback = callback;
+ mMetricsCategory = metricsCategory;
+ mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider();
+ }
+
+ /**
+ * Set the context to generate the {@link Preference}, so it could get the correct theme.
+ */
+ public void initPreference(@NonNull Context context) {
+ initPreference(context, new Injector(context));
+ }
+
+ @VisibleForTesting
+ void initPreference(@NonNull Context context, Injector injector) {
+ mInjector = injector;
+ mPreference = new RestrictedPreference(context, null /* AttributeSet */);
+ mPreference.setTitle(R.string.external_display_settings_title);
+ mPreference.setSummary(getSummary());
+ mPreference.setIcon(getDrawable(context));
+ mPreference.setKey(PREF_KEY);
+ mPreference.setDisabledByAdmin(checkIfUsbDataSignalingIsDisabled(context));
+ mPreference.setOnPreferenceClickListener((Preference p) -> {
+ mMetricsFeatureProvider.logClickedPreference(p, mMetricsCategory);
+ // New version - uses a separate screen.
+ new SubSettingLauncher(context)
+ .setDestination(ExternalDisplayPreferenceFragment.class.getName())
+ .setTitleRes(R.string.external_display_settings_title)
+ .setSourceMetricsCategory(mMetricsCategory)
+ .launch();
+ return true;
+ });
+
+ scheduleUpdate();
+ }
+
+ /**
+ * Unregister the display listener.
+ */
+ public void unregisterCallback() {
+ if (mInjector != null) {
+ mInjector.unregisterDisplayListener(mListener);
+ }
+ }
+
+ /**
+ * Register the display listener.
+ */
+ public void registerCallback() {
+ if (mInjector != null) {
+ mInjector.registerDisplayListener(mListener);
+ }
+ }
+
+ @VisibleForTesting
+ @Nullable
+ protected RestrictedLockUtils.EnforcedAdmin checkIfUsbDataSignalingIsDisabled(Context context) {
+ return RestrictedLockUtilsInternal.checkIfUsbDataSignalingIsDisabled(context,
+ UserHandle.myUserId());
+ }
+
+ @VisibleForTesting
+ @Nullable
+ protected Drawable getDrawable(Context context) {
+ return context.getDrawable(R.drawable.ic_external_display_32dp);
+ }
+
+ @Nullable
+ protected CharSequence getSummary() {
+ if (mInjector == null) {
+ return null;
+ }
+ var context = mInjector.getContext();
+ if (context == null) {
+ return null;
+ }
+
+ for (var display : mInjector.getEnabledDisplays()) {
+ if (display != null && isDisplayAllowed(display, mInjector)) {
+ return context.getString(R.string.external_display_on);
+ }
+ }
+
+ for (var display : mInjector.getAllDisplays()) {
+ if (display != null && isDisplayAllowed(display, mInjector)) {
+ return context.getString(R.string.external_display_off);
+ }
+ }
+
+ return null;
+ }
+
+ private void scheduleUpdate() {
+ if (mInjector == null) {
+ return;
+ }
+ unscheduleUpdate();
+ mInjector.getHandler().post(mUpdateRunnable);
+ }
+
+ private void unscheduleUpdate() {
+ if (mInjector == null) {
+ return;
+ }
+ mInjector.getHandler().removeCallbacks(mUpdateRunnable);
+ }
+
+ private void update() {
+ var summary = getSummary();
+ if (mPreference == null) {
+ return;
+ }
+ mPreference.setSummary(summary);
+ if (summary != null) {
+ mDevicePreferenceCallback.onDeviceAdded(mPreference);
+ } else {
+ mDevicePreferenceCallback.onDeviceRemoved(mPreference);
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/display/OWNERS b/src/com/android/settings/connecteddevice/display/OWNERS
new file mode 100644
index 00000000000..78aecb9dcdf
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/display/OWNERS
@@ -0,0 +1,7 @@
+# Default reviewers for this and subdirectories.
+santoscordon@google.com
+petsjonkin@google.com
+flc@google.com
+wilczynskip@google.com
+brup@google.com
+olb@google.com
diff --git a/src/com/android/settings/connecteddevice/display/ResolutionPreferenceFragment.java b/src/com/android/settings/connecteddevice/display/ResolutionPreferenceFragment.java
new file mode 100644
index 00000000000..10314cb1e21
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/display/ResolutionPreferenceFragment.java
@@ -0,0 +1,343 @@
+/*
+ * 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.display;
+
+import static android.view.Display.INVALID_DISPLAY;
+
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.DISPLAY_ID_ARG;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.EXTERNAL_DISPLAY_HELP_URL;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.EXTERNAL_DISPLAY_NOT_FOUND_RESOURCE;
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.isDisplayAllowed;
+
+import android.app.settings.SettingsEnums;
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.util.Log;
+import android.util.Pair;
+import android.view.Display;
+import android.view.Display.Mode;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.PreferenceScreen;
+
+import com.android.internal.util.ToBooleanFunction;
+import com.android.settings.R;
+import com.android.settings.SettingsPreferenceFragmentBase;
+import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.DisplayListener;
+import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.Injector;
+import com.android.settingslib.widget.SelectorWithWidgetPreference;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+
+public class ResolutionPreferenceFragment extends SettingsPreferenceFragmentBase {
+ private static final String TAG = "ResolutionPreferenceFragment";
+ static final int DEFAULT_LOW_REFRESH_RATE = 60;
+ static final String MORE_OPTIONS_KEY = "more_options";
+ static final String TOP_OPTIONS_KEY = "top_options";
+ static final int MORE_OPTIONS_TITLE_RESOURCE =
+ R.string.external_display_more_options_title;
+ static final int EXTERNAL_DISPLAY_RESOLUTION_SETTINGS_RESOURCE =
+ R.xml.external_display_resolution_settings;
+ @Nullable
+ private Injector mInjector;
+ @Nullable
+ private PreferenceCategory mTopOptionsPreference;
+ @Nullable
+ private PreferenceCategory mMoreOptionsPreference;
+ private boolean mStarted;
+ private final HashSet mResolutionPreferences = new HashSet<>();
+ private int mExternalDisplayPeakWidth;
+ private int mExternalDisplayPeakHeight;
+ private int mExternalDisplayPeakRefreshRate;
+ private boolean mRefreshRateSynchronizationEnabled;
+ private boolean mMoreOptionsExpanded;
+ private final Runnable mUpdateRunnable = this::update;
+ private final DisplayListener mListener = new DisplayListener() {
+ @Override
+ public void update(int displayId) {
+ scheduleUpdate();
+ }
+ };
+
+ @Override
+ public int getMetricsCategory() {
+ return SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY;
+ }
+
+ @Override
+ public int getHelpResource() {
+ return EXTERNAL_DISPLAY_HELP_URL;
+ }
+
+ @Override
+ public void onCreateCallback(@Nullable Bundle icicle) {
+ if (mInjector == null) {
+ mInjector = new Injector(getPrefContext());
+ }
+ addPreferencesFromResource(EXTERNAL_DISPLAY_RESOLUTION_SETTINGS_RESOURCE);
+ updateDisplayModeLimits(mInjector.getContext());
+ }
+
+ @Override
+ public void onActivityCreatedCallback(@Nullable Bundle savedInstanceState) {
+ View view = getView();
+ TextView emptyView = null;
+ if (view != null) {
+ emptyView = (TextView) view.findViewById(android.R.id.empty);
+ }
+ if (emptyView != null) {
+ emptyView.setText(EXTERNAL_DISPLAY_NOT_FOUND_RESOURCE);
+ setEmptyView(emptyView);
+ }
+ }
+
+ @Override
+ public void onStartCallback() {
+ mStarted = true;
+ if (mInjector == null) {
+ return;
+ }
+ mInjector.registerDisplayListener(mListener);
+ scheduleUpdate();
+ }
+
+ @Override
+ public void onStopCallback() {
+ mStarted = false;
+ if (mInjector == null) {
+ return;
+ }
+ mInjector.unregisterDisplayListener(mListener);
+ unscheduleUpdate();
+ }
+
+ public ResolutionPreferenceFragment() {}
+
+ @VisibleForTesting
+ ResolutionPreferenceFragment(@NonNull Injector injector) {
+ mInjector = injector;
+ }
+
+ @VisibleForTesting
+ protected int getDisplayIdArg() {
+ var args = getArguments();
+ return args != null ? args.getInt(DISPLAY_ID_ARG, INVALID_DISPLAY) : INVALID_DISPLAY;
+ }
+
+ @VisibleForTesting
+ @NonNull
+ protected Resources getResources(@NonNull Context context) {
+ return context.getResources();
+ }
+
+ private void update() {
+ final PreferenceScreen screen = getPreferenceScreen();
+ if (screen == null || mInjector == null) {
+ return;
+ }
+ var context = mInjector.getContext();
+ if (context == null) {
+ return;
+ }
+ var display = mInjector.getDisplay(getDisplayIdArg());
+ if (display == null || !isDisplayAllowed(display, mInjector)) {
+ screen.removeAll();
+ mTopOptionsPreference = null;
+ mMoreOptionsPreference = null;
+ return;
+ }
+ mResolutionPreferences.clear();
+ var remainingModes = addModePreferences(context,
+ getTopPreference(context, screen),
+ display.getSupportedModes(), this::isTopMode, display);
+ addRemainingPreferences(context,
+ getMorePreference(context, screen),
+ display, remainingModes.first, remainingModes.second);
+ }
+
+ private PreferenceCategory getTopPreference(@NonNull Context context,
+ @NonNull PreferenceScreen screen) {
+ if (mTopOptionsPreference == null) {
+ mTopOptionsPreference = new PreferenceCategory(context);
+ mTopOptionsPreference.setPersistent(false);
+ mTopOptionsPreference.setKey(TOP_OPTIONS_KEY);
+ screen.addPreference(mTopOptionsPreference);
+ } else {
+ mTopOptionsPreference.removeAll();
+ }
+ return mTopOptionsPreference;
+ }
+
+ private PreferenceCategory getMorePreference(@NonNull Context context,
+ @NonNull PreferenceScreen screen) {
+ if (mMoreOptionsPreference == null) {
+ mMoreOptionsPreference = new PreferenceCategory(context);
+ mMoreOptionsPreference.setPersistent(false);
+ mMoreOptionsPreference.setTitle(MORE_OPTIONS_TITLE_RESOURCE);
+ mMoreOptionsPreference.setOnExpandButtonClickListener(() -> {
+ mMoreOptionsExpanded = true;
+ });
+ mMoreOptionsPreference.setKey(MORE_OPTIONS_KEY);
+ screen.addPreference(mMoreOptionsPreference);
+ } else {
+ mMoreOptionsPreference.removeAll();
+ }
+ return mMoreOptionsPreference;
+ }
+
+ private void addRemainingPreferences(@NonNull Context context,
+ @NonNull PreferenceCategory group, @NonNull Display display,
+ boolean isSelectedModeFound, @NonNull Mode[] moreModes) {
+ if (moreModes.length == 0) {
+ return;
+ }
+ mMoreOptionsExpanded |= !isSelectedModeFound;
+ group.setInitialExpandedChildrenCount(mMoreOptionsExpanded ? Integer.MAX_VALUE : 0);
+ addModePreferences(context, group, moreModes, /*checkMode=*/ null, display);
+ }
+
+ private Pair addModePreferences(@NonNull Context context,
+ @NonNull PreferenceGroup group,
+ @NonNull Mode[] modes,
+ @Nullable ToBooleanFunction checkMode,
+ @NonNull Display display) {
+ Display.Mode curMode = display.getMode();
+ var currentResolution = curMode.getPhysicalWidth() + "x" + curMode.getPhysicalHeight();
+ var rotatedResolution = curMode.getPhysicalHeight() + "x" + curMode.getPhysicalWidth();
+ var skippedModes = new ArrayList();
+ var isAnyOfModesSelected = false;
+ for (var mode : modes) {
+ var modeStr = mode.getPhysicalWidth() + "x" + mode.getPhysicalHeight();
+ SelectorWithWidgetPreference pref = group.findPreference(modeStr);
+ if (pref != null) {
+ continue;
+ }
+ if (checkMode != null && !checkMode.apply(mode)) {
+ skippedModes.add(mode);
+ continue;
+ }
+ var isCurrentMode =
+ currentResolution.equals(modeStr) || rotatedResolution.equals(modeStr);
+ if (!isCurrentMode && !isAllowedMode(mode)) {
+ continue;
+ }
+ if (mResolutionPreferences.contains(modeStr)) {
+ // Added to "Top modes" already.
+ continue;
+ }
+ mResolutionPreferences.add(modeStr);
+ pref = new SelectorWithWidgetPreference(context);
+ pref.setPersistent(false);
+ pref.setKey(modeStr);
+ pref.setTitle(mode.getPhysicalWidth() + " x " + mode.getPhysicalHeight());
+ pref.setSingleLineTitle(true);
+ pref.setOnClickListener(preference -> onDisplayModeClicked(preference, display));
+ pref.setChecked(isCurrentMode);
+ isAnyOfModesSelected |= isCurrentMode;
+ group.addPreference(pref);
+ }
+ return new Pair<>(isAnyOfModesSelected, skippedModes.toArray(Mode.EMPTY_ARRAY));
+ }
+
+ private boolean isTopMode(@NonNull Mode mode) {
+ return mTopOptionsPreference != null
+ && mTopOptionsPreference.getPreferenceCount() < 3;
+ }
+
+ private boolean isAllowedMode(@NonNull Mode mode) {
+ if (mRefreshRateSynchronizationEnabled
+ && (mode.getRefreshRate() < DEFAULT_LOW_REFRESH_RATE - 1
+ || mode.getRefreshRate() > DEFAULT_LOW_REFRESH_RATE + 1)) {
+ Log.d(TAG, mode + " refresh rate is out of synchronization range");
+ return false;
+ }
+ if (mExternalDisplayPeakHeight > 0
+ && mode.getPhysicalHeight() > mExternalDisplayPeakHeight) {
+ Log.d(TAG, mode + " height is above the allowed limit");
+ return false;
+ }
+ if (mExternalDisplayPeakWidth > 0
+ && mode.getPhysicalWidth() > mExternalDisplayPeakWidth) {
+ Log.d(TAG, mode + " width is above the allowed limit");
+ return false;
+ }
+ if (mExternalDisplayPeakRefreshRate > 0
+ && mode.getRefreshRate() > mExternalDisplayPeakRefreshRate) {
+ Log.d(TAG, mode + " refresh rate is above the allowed limit");
+ return false;
+ }
+ return true;
+ }
+
+ private void scheduleUpdate() {
+ if (mInjector == null || !mStarted) {
+ return;
+ }
+ unscheduleUpdate();
+ mInjector.getHandler().post(mUpdateRunnable);
+ }
+
+ private void unscheduleUpdate() {
+ if (mInjector == null || !mStarted) {
+ return;
+ }
+ mInjector.getHandler().removeCallbacks(mUpdateRunnable);
+ }
+
+ private void onDisplayModeClicked(@NonNull SelectorWithWidgetPreference preference,
+ @NonNull Display display) {
+ if (mInjector == null) {
+ return;
+ }
+ String[] modeResolution = preference.getKey().split("x");
+ int width = Integer.parseInt(modeResolution[0]);
+ int height = Integer.parseInt(modeResolution[1]);
+ for (var mode : display.getSupportedModes()) {
+ if (mode.getPhysicalWidth() == width && mode.getPhysicalHeight() == height
+ && isAllowedMode(mode)) {
+ mInjector.setUserPreferredDisplayMode(display.getDisplayId(), mode);
+ return;
+ }
+ }
+ }
+
+ private void updateDisplayModeLimits(@Nullable Context context) {
+ if (context == null) {
+ return;
+ }
+ mExternalDisplayPeakRefreshRate = getResources(context).getInteger(
+ com.android.internal.R.integer.config_externalDisplayPeakRefreshRate);
+ mExternalDisplayPeakWidth = getResources(context).getInteger(
+ com.android.internal.R.integer.config_externalDisplayPeakWidth);
+ mExternalDisplayPeakHeight = getResources(context).getInteger(
+ com.android.internal.R.integer.config_externalDisplayPeakHeight);
+ mRefreshRateSynchronizationEnabled = getResources(context).getBoolean(
+ com.android.internal.R.bool.config_refreshRateSynchronizationEnabled);
+ Log.d(TAG, "mExternalDisplayPeakRefreshRate=" + mExternalDisplayPeakRefreshRate);
+ Log.d(TAG, "mExternalDisplayPeakWidth=" + mExternalDisplayPeakWidth);
+ Log.d(TAG, "mExternalDisplayPeakHeight=" + mExternalDisplayPeakHeight);
+ Log.d(TAG, "mRefreshRateSynchronizationEnabled=" + mRefreshRateSynchronizationEnabled);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceGroupControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceGroupControllerTest.java
index d28ab3b928b..5a9f2bc8692 100644
--- a/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceGroupControllerTest.java
+++ b/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceGroupControllerTest.java
@@ -17,6 +17,8 @@ package com.android.settings.connecteddevice;
import static com.android.settings.core.BasePreferenceController.AVAILABLE_UNSEARCHABLE;
import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+import static com.android.settings.flags.Flags.FLAG_RESOLUTION_AND_ENABLE_CONNECTED_DISPLAY_SETTING;
+import static com.android.settings.flags.Flags.FLAG_ROTATION_CONNECTED_DISPLAY_SETTING;
import static com.google.common.truth.Truth.assertThat;
@@ -30,6 +32,7 @@ import static org.mockito.Mockito.when;
import android.bluetooth.BluetoothDevice;
import android.content.Context;
import android.content.pm.PackageManager;
+import android.content.res.Resources;
import android.hardware.input.InputManager;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule;
@@ -40,13 +43,16 @@ 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.bluetooth.ConnectedBluetoothDeviceUpdater;
import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.display.ExternalDisplayUpdater;
import com.android.settings.connecteddevice.dock.DockUpdater;
import com.android.settings.connecteddevice.stylus.StylusDeviceUpdater;
import com.android.settings.connecteddevice.usb.ConnectedUsbDeviceUpdater;
import com.android.settings.dashboard.DashboardFragment;
+import com.android.settings.flags.FakeFeatureFlagsImpl;
import com.android.settings.flags.Flags;
import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
@@ -65,7 +71,6 @@ import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
-import org.robolectric.RuntimeEnvironment;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowApplicationPackageManager;
@@ -84,6 +89,8 @@ public class ConnectedDeviceGroupControllerTest {
@Mock
private DashboardFragment mDashboardFragment;
@Mock
+ private ExternalDisplayUpdater mExternalDisplayUpdater;
+ @Mock
private ConnectedBluetoothDeviceUpdater mConnectedBluetoothDeviceUpdater;
@Mock
private ConnectedUsbDeviceUpdater mConnectedUsbDeviceUpdater;
@@ -105,6 +112,9 @@ public class ConnectedDeviceGroupControllerTest {
private CachedBluetoothDevice mCachedDevice;
@Mock
private BluetoothDevice mDevice;
+ @Mock
+ private Resources mResources;
+ private final FakeFeatureFlagsImpl mFakeFeatureFlags = new FakeFeatureFlagsImpl();
private ShadowApplicationPackageManager mPackageManager;
private PreferenceGroup mPreferenceGroup;
@@ -118,8 +128,10 @@ public class ConnectedDeviceGroupControllerTest {
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
+ mFakeFeatureFlags.setFlag(FLAG_ROTATION_CONNECTED_DISPLAY_SETTING, true);
+ mFakeFeatureFlags.setFlag(FLAG_RESOLUTION_AND_ENABLE_CONNECTED_DISPLAY_SETTING, true);
- mContext = spy(RuntimeEnvironment.application);
+ mContext = spy(ApplicationProvider.getApplicationContext());
mPreference = new Preference(mContext);
mPreference.setKey(PREFERENCE_KEY_1);
mPackageManager = (ShadowApplicationPackageManager) Shadows.shadowOf(
@@ -129,15 +141,19 @@ public class ConnectedDeviceGroupControllerTest {
doReturn(mContext).when(mDashboardFragment).getContext();
mPackageManager.setSystemFeature(PackageManager.FEATURE_BLUETOOTH, true);
when(mContext.getSystemService(InputManager.class)).thenReturn(mInputManager);
+ when(mContext.getResources()).thenReturn(mResources);
when(mInputManager.getInputDeviceIds()).thenReturn(new int[]{});
ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBluetoothManager;
mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager);
- mConnectedDeviceGroupController = new ConnectedDeviceGroupController(mContext);
- mConnectedDeviceGroupController.init(mConnectedBluetoothDeviceUpdater,
- mConnectedUsbDeviceUpdater, mConnectedDockUpdater, mStylusDeviceUpdater);
+ mConnectedDeviceGroupController = spy(new ConnectedDeviceGroupController(mContext));
+ when(mConnectedDeviceGroupController.getFeatureFlags()).thenReturn(mFakeFeatureFlags);
+
+ mConnectedDeviceGroupController.init(mExternalDisplayUpdater,
+ mConnectedBluetoothDeviceUpdater, mConnectedUsbDeviceUpdater, mConnectedDockUpdater,
+ mStylusDeviceUpdater);
mConnectedDeviceGroupController.mPreferenceGroup = mPreferenceGroup;
when(mCachedDevice.getName()).thenReturn(DEVICE_NAME);
@@ -147,6 +163,7 @@ public class ConnectedDeviceGroupControllerTest {
FeatureFlagUtils.setEnabled(mContext, FeatureFlagUtils.SETTINGS_SHOW_STYLUS_PREFERENCES,
true);
+ when(mPreferenceScreen.getContext()).thenReturn(mContext);
}
@Test
@@ -193,6 +210,7 @@ public class ConnectedDeviceGroupControllerTest {
// register the callback in onStart()
mConnectedDeviceGroupController.onStart();
+ verify(mExternalDisplayUpdater).registerCallback();
verify(mConnectedBluetoothDeviceUpdater).registerCallback();
verify(mConnectedUsbDeviceUpdater).registerCallback();
verify(mConnectedDockUpdater).registerCallback();
@@ -204,6 +222,7 @@ public class ConnectedDeviceGroupControllerTest {
public void onStop_shouldUnregisterUpdaters() {
// unregister the callback in onStop()
mConnectedDeviceGroupController.onStop();
+ verify(mExternalDisplayUpdater).unregisterCallback();
verify(mConnectedBluetoothDeviceUpdater).unregisterCallback();
verify(mConnectedUsbDeviceUpdater).unregisterCallback();
verify(mConnectedDockUpdater).unregisterCallback();
@@ -212,22 +231,36 @@ public class ConnectedDeviceGroupControllerTest {
@Test
public void getAvailabilityStatus_noBluetoothUsbDockFeature_returnUnSupported() {
+ mFakeFeatureFlags.setFlag(FLAG_ROTATION_CONNECTED_DISPLAY_SETTING, false);
+ mFakeFeatureFlags.setFlag(FLAG_RESOLUTION_AND_ENABLE_CONNECTED_DISPLAY_SETTING, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_BLUETOOTH, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_ACCESSORY, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_HOST, false);
- mConnectedDeviceGroupController.init(mConnectedBluetoothDeviceUpdater,
+ mConnectedDeviceGroupController.init(null, mConnectedBluetoothDeviceUpdater,
mConnectedUsbDeviceUpdater, null, null);
assertThat(mConnectedDeviceGroupController.getAvailabilityStatus()).isEqualTo(
UNSUPPORTED_ON_DEVICE);
}
+ @Test
+ public void getAvailabilityStatus_connectedDisplay_returnSupported() {
+ mPackageManager.setSystemFeature(PackageManager.FEATURE_BLUETOOTH, false);
+ mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_ACCESSORY, false);
+ mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_HOST, false);
+ mConnectedDeviceGroupController.init(null, mConnectedBluetoothDeviceUpdater,
+ mConnectedUsbDeviceUpdater, null, null);
+
+ assertThat(mConnectedDeviceGroupController.getAvailabilityStatus()).isEqualTo(
+ AVAILABLE_UNSEARCHABLE);
+ }
+
@Test
public void getAvailabilityStatus_BluetoothFeature_returnSupported() {
mPackageManager.setSystemFeature(PackageManager.FEATURE_BLUETOOTH, true);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_ACCESSORY, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_HOST, false);
- mConnectedDeviceGroupController.init(mConnectedBluetoothDeviceUpdater,
+ mConnectedDeviceGroupController.init(null, mConnectedBluetoothDeviceUpdater,
mConnectedUsbDeviceUpdater, null, null);
assertThat(mConnectedDeviceGroupController.getAvailabilityStatus()).isEqualTo(
@@ -239,7 +272,7 @@ public class ConnectedDeviceGroupControllerTest {
mPackageManager.setSystemFeature(PackageManager.FEATURE_BLUETOOTH, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_ACCESSORY, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_HOST, true);
- mConnectedDeviceGroupController.init(mConnectedBluetoothDeviceUpdater,
+ mConnectedDeviceGroupController.init(null, mConnectedBluetoothDeviceUpdater,
mConnectedUsbDeviceUpdater, null, null);
assertThat(mConnectedDeviceGroupController.getAvailabilityStatus()).isEqualTo(
@@ -251,7 +284,7 @@ public class ConnectedDeviceGroupControllerTest {
mPackageManager.setSystemFeature(PackageManager.FEATURE_BLUETOOTH, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_ACCESSORY, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_HOST, false);
- mConnectedDeviceGroupController.init(mConnectedBluetoothDeviceUpdater,
+ mConnectedDeviceGroupController.init(null, mConnectedBluetoothDeviceUpdater,
mConnectedUsbDeviceUpdater, mConnectedDockUpdater, null);
assertThat(mConnectedDeviceGroupController.getAvailabilityStatus()).isEqualTo(
@@ -261,6 +294,8 @@ public class ConnectedDeviceGroupControllerTest {
@Test
public void getAvailabilityStatus_noUsiStylusFeature_returnUnSupported() {
+ mFakeFeatureFlags.setFlag(FLAG_ROTATION_CONNECTED_DISPLAY_SETTING, false);
+ mFakeFeatureFlags.setFlag(FLAG_RESOLUTION_AND_ENABLE_CONNECTED_DISPLAY_SETTING, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_BLUETOOTH, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_ACCESSORY, false);
mPackageManager.setSystemFeature(PackageManager.FEATURE_USB_HOST, false);
@@ -268,7 +303,7 @@ public class ConnectedDeviceGroupControllerTest {
when(mInputManager.getInputDevice(0)).thenReturn(new InputDevice.Builder().setSources(
InputDevice.SOURCE_DPAD).setExternal(false).build());
- mConnectedDeviceGroupController.init(mConnectedBluetoothDeviceUpdater,
+ mConnectedDeviceGroupController.init(null, mConnectedBluetoothDeviceUpdater,
mConnectedUsbDeviceUpdater, null, mStylusDeviceUpdater);
assertThat(mConnectedDeviceGroupController.getAvailabilityStatus()).isEqualTo(
@@ -284,7 +319,7 @@ public class ConnectedDeviceGroupControllerTest {
when(mInputManager.getInputDevice(0)).thenReturn(new InputDevice.Builder().setSources(
InputDevice.SOURCE_STYLUS).setExternal(false).build());
- mConnectedDeviceGroupController.init(mConnectedBluetoothDeviceUpdater,
+ mConnectedDeviceGroupController.init(null, mConnectedBluetoothDeviceUpdater,
mConnectedUsbDeviceUpdater, mConnectedDockUpdater, mStylusDeviceUpdater);
assertThat(mConnectedDeviceGroupController.getAvailabilityStatus()).isEqualTo(
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index bc5824f6cb2..55df480e787 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -33,6 +33,7 @@ android_test {
"kotlinx_coroutines_test",
"Settings-testutils2",
"MediaDrmSettingsFlagsLib",
+ "servicestests-utils",
// Don't add SettingsLib libraries here - you can use them directly as they are in the
// instrumented Settings app.
],
diff --git a/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayPreferenceFragmentTest.java b/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayPreferenceFragmentTest.java
new file mode 100644
index 00000000000..019ade7ae58
--- /dev/null
+++ b/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayPreferenceFragmentTest.java
@@ -0,0 +1,409 @@
+/*
+ * 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.display;
+
+
+import static android.view.Display.INVALID_DISPLAY;
+
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.PREVIOUSLY_SHOWN_LIST_KEY;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.DISPLAYS_LIST_PREFERENCE_KEY;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_CHANGE_RESOLUTION_FOOTER_RESOURCE;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_NOT_FOUND_FOOTER_RESOURCE;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_RESOLUTION_PREFERENCE_KEY;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_RESOLUTION_TITLE_RESOURCE;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_ROTATION_KEY;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_ROTATION_TITLE_RESOURCE;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_SETTINGS_RESOURCE;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_USE_PREFERENCE_KEY;
+import static com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.EXTERNAL_DISPLAY_USE_TITLE_RESOURCE;
+import static com.android.settingslib.widget.FooterPreference.KEY_FOOTER;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.Display;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceScreen;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.settings.connecteddevice.display.ExternalDisplayPreferenceFragment.DisplayPreference;
+import com.android.settingslib.widget.FooterPreference;
+import com.android.settingslib.widget.MainSwitchPreference;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+/** Unit tests for {@link ExternalDisplayPreferenceFragment}. */
+@RunWith(AndroidJUnit4.class)
+public class ExternalDisplayPreferenceFragmentTest extends ExternalDisplayTestBase {
+ @Nullable
+ private ExternalDisplayPreferenceFragment mFragment;
+ private int mPreferenceIdFromResource;
+ private int mDisplayIdArg = INVALID_DISPLAY;
+ private int mResolutionSelectorDisplayId = INVALID_DISPLAY;
+ @Mock
+ private MetricsLogger mMockedMetricsLogger;
+
+ @Test
+ @UiThreadTest
+ public void testCreateAndStart() {
+ initFragment();
+ assertThat(mPreferenceIdFromResource).isEqualTo(EXTERNAL_DISPLAY_SETTINGS_RESOURCE);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testShowDisplayList() {
+ var fragment = initFragment();
+ var outState = new Bundle();
+ fragment.onSaveInstanceStateCallback(outState);
+ assertThat(outState.getBoolean(PREVIOUSLY_SHOWN_LIST_KEY)).isFalse();
+ assertThat(mHandler.getPendingMessages().size()).isEqualTo(1);
+ PreferenceCategory pref = mPreferenceScreen.findPreference(DISPLAYS_LIST_PREFERENCE_KEY);
+ assertThat(pref).isNull();
+ verify(mMockedInjector, never()).getAllDisplays();
+ mHandler.flush();
+ assertThat(mHandler.getPendingMessages().size()).isEqualTo(0);
+ verify(mMockedInjector).getAllDisplays();
+ pref = mPreferenceScreen.findPreference(DISPLAYS_LIST_PREFERENCE_KEY);
+ assertThat(pref).isNotNull();
+ assertThat(pref.getPreferenceCount()).isEqualTo(2);
+ fragment.onSaveInstanceStateCallback(outState);
+ assertThat(outState.getBoolean(PREVIOUSLY_SHOWN_LIST_KEY)).isTrue();
+ }
+
+ @Test
+ @UiThreadTest
+ public void testLaunchDisplaySettingFromList() {
+ initFragment();
+ mHandler.flush();
+ PreferenceCategory pref = mPreferenceScreen.findPreference(DISPLAYS_LIST_PREFERENCE_KEY);
+ assertThat(pref).isNotNull();
+ DisplayPreference display1Pref = (DisplayPreference) pref.getPreference(0);
+ DisplayPreference display2Pref = (DisplayPreference) pref.getPreference(1);
+ assertThat(display1Pref.getKey()).isEqualTo("display_id_" + 1);
+ assertThat("" + display1Pref.getTitle()).isEqualTo("HDMI");
+ assertThat("" + display1Pref.getSummary()).isEqualTo("1920 x 1080");
+ display1Pref.onPreferenceClick(display1Pref);
+ assertThat(mDisplayIdArg).isEqualTo(1);
+ verify(mMockedMetricsLogger).writePreferenceClickMetric(display1Pref);
+ assertThat(display2Pref.getKey()).isEqualTo("display_id_" + 2);
+ assertThat("" + display2Pref.getTitle()).isEqualTo("Overlay #1");
+ assertThat("" + display2Pref.getSummary()).isEqualTo("1240 x 780");
+ display2Pref.onPreferenceClick(display2Pref);
+ assertThat(mDisplayIdArg).isEqualTo(2);
+ verify(mMockedMetricsLogger).writePreferenceClickMetric(display2Pref);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testShowDisplayListForOnlyOneDisplay_PreviouslyShownList() {
+ var fragment = initFragment();
+ // Previously shown list of displays
+ fragment.onActivityCreatedCallback(createBundleForPreviouslyShownList());
+ // Only one display available
+ doReturn(new Display[] {mDisplays[1]}).when(mMockedInjector).getAllDisplays();
+ mHandler.flush();
+ PreferenceCategory pref = mPreferenceScreen.findPreference(DISPLAYS_LIST_PREFERENCE_KEY);
+ assertThat(pref).isNotNull();
+ assertThat(pref.getPreferenceCount()).isEqualTo(1);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testShowEnabledDisplay_OnlyOneDisplayAvailable() {
+ doReturn(true).when(mMockedInjector).isDisplayEnabled(any());
+ // Only one display available
+ doReturn(new Display[] {mDisplays[1]}).when(mMockedInjector).getAllDisplays();
+ // Init
+ initFragment();
+ mHandler.flush();
+ PreferenceCategory list = mPreferenceScreen.findPreference(DISPLAYS_LIST_PREFERENCE_KEY);
+ assertThat(list).isNull();
+ var pref = mPreferenceScreen.findPreference(EXTERNAL_DISPLAY_RESOLUTION_PREFERENCE_KEY);
+ assertThat(pref).isNotNull();
+ pref = mPreferenceScreen.findPreference(EXTERNAL_DISPLAY_ROTATION_KEY);
+ assertThat(pref).isNotNull();
+ var footerPref = (FooterPreference) mPreferenceScreen.findPreference(KEY_FOOTER);
+ assertThat(footerPref).isNotNull();
+ verify(footerPref).setTitle(EXTERNAL_DISPLAY_CHANGE_RESOLUTION_FOOTER_RESOURCE);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testShowOneEnabledDisplay_FewAvailable() {
+ mDisplayIdArg = 1;
+ doReturn(true).when(mMockedInjector).isDisplayEnabled(any());
+ initFragment();
+ verify(mMockedInjector, never()).getDisplay(anyInt());
+ mHandler.flush();
+ verify(mMockedInjector).getDisplay(mDisplayIdArg);
+ var pref = mPreferenceScreen.findPreference(EXTERNAL_DISPLAY_RESOLUTION_PREFERENCE_KEY);
+ assertThat(pref).isNotNull();
+ pref = mPreferenceScreen.findPreference(EXTERNAL_DISPLAY_ROTATION_KEY);
+ assertThat(pref).isNotNull();
+ var footerPref = (FooterPreference) mPreferenceScreen.findPreference(KEY_FOOTER);
+ assertThat(footerPref).isNotNull();
+ verify(footerPref).setTitle(EXTERNAL_DISPLAY_CHANGE_RESOLUTION_FOOTER_RESOURCE);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testShowDisabledDisplay() {
+ mDisplayIdArg = 1;
+ initFragment();
+ verify(mMockedInjector, never()).getDisplay(anyInt());
+ mHandler.flush();
+ verify(mMockedInjector).getDisplay(mDisplayIdArg);
+ var mainPref = (MainSwitchPreference) mPreferenceScreen.findPreference(
+ EXTERNAL_DISPLAY_USE_PREFERENCE_KEY);
+ assertThat(mainPref).isNotNull();
+ assertThat("" + mainPref.getTitle()).isEqualTo(
+ getText(EXTERNAL_DISPLAY_USE_TITLE_RESOURCE));
+ assertThat(mainPref.isChecked()).isFalse();
+ assertThat(mainPref.isEnabled()).isTrue();
+ assertThat(mainPref.getOnPreferenceChangeListener()).isNotNull();
+ var pref = mPreferenceScreen.findPreference(EXTERNAL_DISPLAY_RESOLUTION_PREFERENCE_KEY);
+ assertThat(pref).isNull();
+ pref = mPreferenceScreen.findPreference(EXTERNAL_DISPLAY_ROTATION_KEY);
+ assertThat(pref).isNull();
+ var footerPref = (FooterPreference) mPreferenceScreen.findPreference(KEY_FOOTER);
+ assertThat(footerPref).isNull();
+ }
+
+ @Test
+ @UiThreadTest
+ public void testNoDisplays() {
+ doReturn(new Display[0]).when(mMockedInjector).getAllDisplays();
+ initFragment();
+ mHandler.flush();
+ var mainPref = (MainSwitchPreference) mPreferenceScreen.findPreference(
+ EXTERNAL_DISPLAY_USE_PREFERENCE_KEY);
+ assertThat(mainPref).isNotNull();
+ assertThat("" + mainPref.getTitle()).isEqualTo(
+ getText(EXTERNAL_DISPLAY_USE_TITLE_RESOURCE));
+ assertThat(mainPref.isChecked()).isFalse();
+ assertThat(mainPref.isEnabled()).isFalse();
+ assertThat(mainPref.getOnPreferenceChangeListener()).isNull();
+ var footerPref = (FooterPreference) mPreferenceScreen.findPreference(KEY_FOOTER);
+ assertThat(footerPref).isNotNull();
+ verify(footerPref).setTitle(EXTERNAL_DISPLAY_NOT_FOUND_FOOTER_RESOURCE);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testDisplayRotationPreference() {
+ mDisplayIdArg = 1;
+ doReturn(true).when(mMockedInjector).isDisplayEnabled(any());
+ var fragment = initFragment();
+ mHandler.flush();
+ var pref = fragment.getRotationPreference(mContext);
+ assertThat(pref.getKey()).isEqualTo(EXTERNAL_DISPLAY_ROTATION_KEY);
+ assertThat("" + pref.getTitle()).isEqualTo(
+ getText(EXTERNAL_DISPLAY_ROTATION_TITLE_RESOURCE));
+ assertThat(pref.getEntries().length).isEqualTo(4);
+ assertThat(pref.getEntryValues().length).isEqualTo(4);
+ assertThat(pref.getEntryValues()[0].toString()).isEqualTo("0");
+ assertThat(pref.getEntryValues()[1].toString()).isEqualTo("1");
+ assertThat(pref.getEntryValues()[2].toString()).isEqualTo("2");
+ assertThat(pref.getEntryValues()[3].toString()).isEqualTo("3");
+ assertThat(pref.getEntries()[0].length()).isGreaterThan(0);
+ assertThat(pref.getEntries()[1].length()).isGreaterThan(0);
+ assertThat("" + pref.getSummary()).isEqualTo(pref.getEntries()[0].toString());
+ assertThat(pref.getValue()).isEqualTo("0");
+ assertThat(pref.getOnPreferenceChangeListener()).isNotNull();
+ assertThat(pref.isEnabled()).isTrue();
+ var rotation = 1;
+ doReturn(true).when(mMockedInjector).freezeDisplayRotation(mDisplayIdArg, rotation);
+ assertThat(pref.getOnPreferenceChangeListener().onPreferenceChange(pref, rotation + ""))
+ .isTrue();
+ verify(mMockedInjector).freezeDisplayRotation(mDisplayIdArg, rotation);
+ assertThat(pref.getValue()).isEqualTo(rotation + "");
+ verify(mMockedMetricsLogger).writePreferenceClickMetric(pref);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testDisplayResolutionPreference() {
+ mDisplayIdArg = 1;
+ doReturn(true).when(mMockedInjector).isDisplayEnabled(any());
+ var fragment = initFragment();
+ mHandler.flush();
+ var pref = fragment.getResolutionPreference(mContext);
+ assertThat(pref.getKey()).isEqualTo(EXTERNAL_DISPLAY_RESOLUTION_PREFERENCE_KEY);
+ assertThat("" + pref.getTitle()).isEqualTo(
+ getText(EXTERNAL_DISPLAY_RESOLUTION_TITLE_RESOURCE));
+ assertThat("" + pref.getSummary()).isEqualTo("1920 x 1080");
+ assertThat(pref.isEnabled()).isTrue();
+ assertThat(pref.getOnPreferenceClickListener()).isNotNull();
+ assertThat(pref.getOnPreferenceClickListener().onPreferenceClick(pref)).isTrue();
+ assertThat(mResolutionSelectorDisplayId).isEqualTo(mDisplayIdArg);
+ verify(mMockedMetricsLogger).writePreferenceClickMetric(pref);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testUseDisplayPreference_EnabledDisplay() {
+ mDisplayIdArg = 1;
+ doReturn(true).when(mMockedInjector).isDisplayEnabled(any());
+ doReturn(true).when(mMockedInjector).enableConnectedDisplay(mDisplayIdArg);
+ doReturn(true).when(mMockedInjector).disableConnectedDisplay(mDisplayIdArg);
+ var fragment = initFragment();
+ mHandler.flush();
+ var pref = fragment.getUseDisplayPreference(mContext);
+ assertThat(pref.getKey()).isEqualTo(EXTERNAL_DISPLAY_USE_PREFERENCE_KEY);
+ assertThat("" + pref.getTitle()).isEqualTo(getText(EXTERNAL_DISPLAY_USE_TITLE_RESOURCE));
+ assertThat(pref.isEnabled()).isTrue();
+ assertThat(pref.isChecked()).isTrue();
+ assertThat(pref.getOnPreferenceChangeListener()).isNotNull();
+ assertThat(pref.getOnPreferenceChangeListener().onPreferenceChange(pref, false)).isTrue();
+ verify(mMockedInjector).disableConnectedDisplay(mDisplayIdArg);
+ assertThat(pref.isChecked()).isFalse();
+ assertThat(pref.getOnPreferenceChangeListener().onPreferenceChange(pref, true)).isTrue();
+ verify(mMockedInjector).enableConnectedDisplay(mDisplayIdArg);
+ assertThat(pref.isChecked()).isTrue();
+ verify(mMockedMetricsLogger, times(2)).writePreferenceClickMetric(pref);
+ }
+
+ @NonNull
+ private ExternalDisplayPreferenceFragment initFragment() {
+ if (mFragment != null) {
+ return mFragment;
+ }
+ mFragment = new TestableExternalDisplayPreferenceFragment();
+ mFragment.onCreateCallback(null);
+ mFragment.onActivityCreatedCallback(null);
+ mFragment.onStartCallback();
+ return mFragment;
+ }
+
+ @NonNull
+ private Bundle createBundleForPreviouslyShownList() {
+ var state = new Bundle();
+ state.putBoolean(PREVIOUSLY_SHOWN_LIST_KEY, true);
+ return state;
+ }
+
+ @NonNull
+ private String getText(int id) {
+ return mContext.getResources().getText(id).toString();
+ }
+
+ private class TestableExternalDisplayPreferenceFragment extends
+ ExternalDisplayPreferenceFragment {
+ private final View mMockedRootView;
+ private final TextView mEmptyView;
+ private final Activity mMockedActivity;
+ private final FooterPreference mMockedFooterPreference;
+ private final MetricsLogger mLogger;
+
+ TestableExternalDisplayPreferenceFragment() {
+ super(mMockedInjector);
+ mMockedActivity = mock(Activity.class);
+ mMockedRootView = mock(View.class);
+ mMockedFooterPreference = mock(FooterPreference.class);
+ doReturn(KEY_FOOTER).when(mMockedFooterPreference).getKey();
+ mEmptyView = new TextView(mContext);
+ doReturn(mEmptyView).when(mMockedRootView).findViewById(android.R.id.empty);
+ mLogger = mMockedMetricsLogger;
+ }
+
+ @Override
+ public PreferenceScreen getPreferenceScreen() {
+ return mPreferenceScreen;
+ }
+
+ @Override
+ protected Activity getCurrentActivity() {
+ return mMockedActivity;
+ }
+
+ @Override
+ public View getView() {
+ return mMockedRootView;
+ }
+
+ @Override
+ public void setEmptyView(View view) {
+ assertThat(view).isEqualTo(mEmptyView);
+ }
+
+ @Override
+ public View getEmptyView() {
+ return mEmptyView;
+ }
+
+ @Override
+ public void addPreferencesFromResource(int resource) {
+ mPreferenceIdFromResource = resource;
+ }
+
+ @Override
+ @NonNull
+ FooterPreference getFooterPreference(@NonNull Context context) {
+ return mMockedFooterPreference;
+ }
+
+ @Override
+ protected int getDisplayIdArg() {
+ return mDisplayIdArg;
+ }
+
+ @Override
+ protected void launchResolutionSelector(@NonNull Context context, int displayId) {
+ mResolutionSelectorDisplayId = displayId;
+ }
+
+ @Override
+ protected void launchDisplaySettings(final int displayId) {
+ mDisplayIdArg = displayId;
+ }
+
+ @Override
+ protected void writePreferenceClickMetric(Preference preference) {
+ mLogger.writePreferenceClickMetric(preference);
+ }
+ }
+
+ /**
+ * Interface allowing to mock and spy on log events.
+ */
+ public interface MetricsLogger {
+
+ /**
+ * On preference click metric
+ */
+ void writePreferenceClickMetric(Preference preference);
+ }
+}
diff --git a/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayTestBase.java b/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayTestBase.java
new file mode 100644
index 00000000000..60b034288a0
--- /dev/null
+++ b/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayTestBase.java
@@ -0,0 +1,149 @@
+/*
+ * 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.display;
+
+import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.VIRTUAL_DISPLAY_PACKAGE_NAME_SYSTEM_PROPERTY;
+import static com.android.settings.flags.Flags.FLAG_ROTATION_CONNECTED_DISPLAY_SETTING;
+import static com.android.settings.flags.Flags.FLAG_RESOLUTION_AND_ENABLE_CONNECTED_DISPLAY_SETTING;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.hardware.display.DisplayManagerGlobal;
+import android.hardware.display.IDisplayManager;
+import android.os.RemoteException;
+import android.view.Display;
+import android.view.DisplayAdjustments;
+import android.view.DisplayInfo;
+
+import androidx.preference.PreferenceManager;
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.server.testutils.TestHandler;
+import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.DisplayListener;
+import com.android.settings.flags.FakeFeatureFlagsImpl;
+
+import org.junit.Before;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class ExternalDisplayTestBase {
+ @Mock
+ ExternalDisplaySettingsConfiguration.Injector mMockedInjector;
+ @Mock
+ IDisplayManager mMockedIDisplayManager;
+ Resources mResources;
+ DisplayManagerGlobal mDisplayManagerGlobal;
+ FakeFeatureFlagsImpl mFlags = new FakeFeatureFlagsImpl();
+ Context mContext;
+ DisplayListener mListener;
+ TestHandler mHandler = new TestHandler(null);
+ PreferenceManager mPreferenceManager;
+ PreferenceScreen mPreferenceScreen;
+ Display[] mDisplays;
+
+ /**
+ * Setup.
+ */
+ @Before
+ public void setUp() throws RemoteException {
+ MockitoAnnotations.initMocks(this);
+ mContext = spy(ApplicationProvider.getApplicationContext());
+ mResources = spy(mContext.getResources());
+ doReturn(mResources).when(mContext).getResources();
+ mPreferenceManager = new PreferenceManager(mContext);
+ mPreferenceScreen = mPreferenceManager.createPreferenceScreen(mContext);
+ doReturn(0).when(mMockedIDisplayManager).getPreferredWideGamutColorSpaceId();
+ mDisplayManagerGlobal = new DisplayManagerGlobal(mMockedIDisplayManager);
+ mFlags.setFlag(FLAG_ROTATION_CONNECTED_DISPLAY_SETTING, true);
+ mFlags.setFlag(FLAG_RESOLUTION_AND_ENABLE_CONNECTED_DISPLAY_SETTING, true);
+ mDisplays = new Display[] {
+ createDefaultDisplay(), createExternalDisplay(), createOverlayDisplay()};
+ doReturn(mDisplays).when(mMockedInjector).getAllDisplays();
+ doReturn(mDisplays).when(mMockedInjector).getEnabledDisplays();
+ for (var display : mDisplays) {
+ doReturn(display).when(mMockedInjector).getDisplay(display.getDisplayId());
+ }
+ doReturn(mFlags).when(mMockedInjector).getFlags();
+ doReturn(mHandler).when(mMockedInjector).getHandler();
+ doReturn("").when(mMockedInjector).getSystemProperty(
+ VIRTUAL_DISPLAY_PACKAGE_NAME_SYSTEM_PROPERTY);
+ doAnswer((arg) -> {
+ mListener = arg.getArgument(0);
+ return null;
+ }).when(mMockedInjector).registerDisplayListener(any());
+ doReturn(0).when(mMockedInjector).getDisplayUserRotation(anyInt());
+ doReturn(mContext).when(mMockedInjector).getContext();
+ }
+
+ Display createDefaultDisplay() throws RemoteException {
+ int displayId = 0;
+ var displayInfo = new DisplayInfo();
+ doReturn(displayInfo).when(mMockedIDisplayManager).getDisplayInfo(displayId);
+ displayInfo.displayId = displayId;
+ displayInfo.name = "Built-in";
+ displayInfo.type = Display.TYPE_INTERNAL;
+ displayInfo.supportedModes = new Display.Mode[]{
+ new Display.Mode(0, 2048, 1024, 60, 60, new float[0],
+ new int[0])};
+ displayInfo.appsSupportedModes = displayInfo.supportedModes;
+ return createDisplay(displayInfo);
+ }
+
+ Display createExternalDisplay() throws RemoteException {
+ int displayId = 1;
+ var displayInfo = new DisplayInfo();
+ doReturn(displayInfo).when(mMockedIDisplayManager).getDisplayInfo(displayId);
+ displayInfo.displayId = displayId;
+ displayInfo.name = "HDMI";
+ displayInfo.type = Display.TYPE_EXTERNAL;
+ displayInfo.supportedModes = new Display.Mode[]{
+ new Display.Mode(0, 1920, 1080, 60, 60, new float[0], new int[0]),
+ new Display.Mode(1, 800, 600, 60, 60, new float[0], new int[0]),
+ new Display.Mode(2, 320, 240, 70, 70, new float[0], new int[0]),
+ new Display.Mode(3, 640, 480, 60, 60, new float[0], new int[0]),
+ new Display.Mode(4, 640, 480, 50, 60, new float[0], new int[0]),
+ new Display.Mode(5, 2048, 1024, 60, 60, new float[0], new int[0]),
+ new Display.Mode(6, 720, 480, 60, 60, new float[0], new int[0])};
+ displayInfo.appsSupportedModes = displayInfo.supportedModes;
+ return createDisplay(displayInfo);
+ }
+
+ Display createOverlayDisplay() throws RemoteException {
+ int displayId = 2;
+ var displayInfo = new DisplayInfo();
+ doReturn(displayInfo).when(mMockedIDisplayManager).getDisplayInfo(displayId);
+ displayInfo.displayId = displayId;
+ displayInfo.name = "Overlay #1";
+ displayInfo.type = Display.TYPE_OVERLAY;
+ displayInfo.supportedModes = new Display.Mode[]{
+ new Display.Mode(0, 1240, 780, 60, 60, new float[0],
+ new int[0])};
+ displayInfo.appsSupportedModes = displayInfo.supportedModes;
+ return createDisplay(displayInfo);
+ }
+
+ Display createDisplay(DisplayInfo displayInfo) {
+ return new Display(mDisplayManagerGlobal, displayInfo.displayId, displayInfo,
+ (DisplayAdjustments) null);
+ }
+}
diff --git a/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayUpdaterTest.java b/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayUpdaterTest.java
new file mode 100644
index 00000000000..824974ad854
--- /dev/null
+++ b/tests/unit/src/com/android/settings/connecteddevice/display/ExternalDisplayUpdaterTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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.display;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.RemoteException;
+import android.view.Display;
+
+import androidx.annotation.Nullable;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settingslib.RestrictedLockUtils;
+import com.android.settingslib.RestrictedPreference;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+/** Unit tests for {@link ExternalDisplayUpdater}. */
+@RunWith(AndroidJUnit4.class)
+public class ExternalDisplayUpdaterTest extends ExternalDisplayTestBase {
+
+ private ExternalDisplayUpdater mUpdater;
+ @Mock
+ private DevicePreferenceCallback mMockedCallback;
+ @Mock
+ private Drawable mMockedDrawable;
+ private RestrictedPreference mPreferenceAdded;
+ private RestrictedPreference mPreferenceRemoved;
+
+ @Before
+ public void setUp() throws RemoteException {
+ super.setUp();
+ mUpdater = new TestableExternalDisplayUpdater(mMockedCallback, /*metricsCategory=*/ 0);
+ }
+
+ @Test
+ public void testPreferenceAdded() {
+ doAnswer((v) -> {
+ mPreferenceAdded = v.getArgument(0);
+ return null;
+ }).when(mMockedCallback).onDeviceAdded(any());
+ mUpdater.initPreference(mContext, mMockedInjector);
+ mUpdater.registerCallback();
+ mHandler.flush();
+ assertThat(mPreferenceAdded).isNotNull();
+ var summary = mPreferenceAdded.getSummary();
+ assertThat(summary).isNotNull();
+ assertThat(summary.length()).isGreaterThan(0);
+ var title = mPreferenceAdded.getTitle();
+ assertThat(title).isNotNull();
+ assertThat(title.length()).isGreaterThan(0);
+ }
+
+ @Test
+ public void testPreferenceRemoved() {
+ doAnswer((v) -> {
+ mPreferenceAdded = v.getArgument(0);
+ return null;
+ }).when(mMockedCallback).onDeviceAdded(any());
+ doAnswer((v) -> {
+ mPreferenceRemoved = v.getArgument(0);
+ return null;
+ }).when(mMockedCallback).onDeviceRemoved(any());
+ mUpdater.initPreference(mContext, mMockedInjector);
+ mUpdater.registerCallback();
+ mHandler.flush();
+ assertThat(mPreferenceAdded).isNotNull();
+ assertThat(mPreferenceRemoved).isNull();
+ // Remove display
+ doReturn(new Display[0]).when(mMockedInjector).getAllDisplays();
+ doReturn(new Display[0]).when(mMockedInjector).getEnabledDisplays();
+ mListener.onDisplayRemoved(1);
+ mHandler.flush();
+ assertThat(mPreferenceRemoved).isEqualTo(mPreferenceAdded);
+ }
+
+ class TestableExternalDisplayUpdater extends ExternalDisplayUpdater {
+ TestableExternalDisplayUpdater(
+ DevicePreferenceCallback callback,
+ int metricsCategory) {
+ super(callback, metricsCategory);
+ }
+
+ @Override
+ @Nullable
+ protected RestrictedLockUtils.EnforcedAdmin checkIfUsbDataSignalingIsDisabled(
+ Context context) {
+ // if null is returned - usb signalling is enabled
+ return null;
+ }
+
+ @Override
+ @Nullable
+ protected Drawable getDrawable(Context context) {
+ return mMockedDrawable;
+ }
+ }
+}
diff --git a/tests/unit/src/com/android/settings/connecteddevice/display/ResolutionPreferenceFragmentTest.java b/tests/unit/src/com/android/settings/connecteddevice/display/ResolutionPreferenceFragmentTest.java
new file mode 100644
index 00000000000..ee38a1cbae2
--- /dev/null
+++ b/tests/unit/src/com/android/settings/connecteddevice/display/ResolutionPreferenceFragmentTest.java
@@ -0,0 +1,195 @@
+/*
+ * 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.display;
+
+import static android.view.Display.INVALID_DISPLAY;
+
+import static com.android.settings.connecteddevice.display.ResolutionPreferenceFragment.EXTERNAL_DISPLAY_RESOLUTION_SETTINGS_RESOURCE;
+import static com.android.settings.connecteddevice.display.ResolutionPreferenceFragment.MORE_OPTIONS_KEY;
+import static com.android.settings.connecteddevice.display.ResolutionPreferenceFragment.TOP_OPTIONS_KEY;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceScreen;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.settingslib.widget.SelectorWithWidgetPreference;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+/** Unit tests for {@link ResolutionPreferenceFragment}. */
+@RunWith(AndroidJUnit4.class)
+public class ResolutionPreferenceFragmentTest extends ExternalDisplayTestBase {
+ @Nullable
+ private ResolutionPreferenceFragment mFragment;
+ private int mPreferenceIdFromResource;
+ private int mDisplayIdArg = INVALID_DISPLAY;
+ @Mock
+ private MetricsLogger mMockedMetricsLogger;
+
+ @Test
+ @UiThreadTest
+ public void testCreateAndStart() {
+ initFragment();
+ mHandler.flush();
+ assertThat(mPreferenceIdFromResource).isEqualTo(
+ EXTERNAL_DISPLAY_RESOLUTION_SETTINGS_RESOURCE);
+ var pref = mPreferenceScreen.findPreference(TOP_OPTIONS_KEY);
+ assertThat(pref).isNull();
+ pref = mPreferenceScreen.findPreference(MORE_OPTIONS_KEY);
+ assertThat(pref).isNull();
+ }
+
+ @Test
+ @UiThreadTest
+ public void testCreateAndStartDefaultDisplayNotAllowed() {
+ mDisplayIdArg = 0;
+ initFragment();
+ mHandler.flush();
+ var pref = mPreferenceScreen.findPreference(TOP_OPTIONS_KEY);
+ assertThat(pref).isNull();
+ pref = mPreferenceScreen.findPreference(MORE_OPTIONS_KEY);
+ assertThat(pref).isNull();
+ }
+
+ @Test
+ @UiThreadTest
+ public void testModePreferences() {
+ mDisplayIdArg = 1;
+ initFragment();
+ mHandler.flush();
+ PreferenceCategory topPref = mPreferenceScreen.findPreference(TOP_OPTIONS_KEY);
+ assertThat(topPref).isNotNull();
+ PreferenceCategory morePref = mPreferenceScreen.findPreference(MORE_OPTIONS_KEY);
+ assertThat(morePref).isNotNull();
+ assertThat(topPref.getPreferenceCount()).isEqualTo(3);
+ assertThat(morePref.getPreferenceCount()).isEqualTo(1);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testModeChange() {
+ mDisplayIdArg = 1;
+ initFragment();
+ mHandler.flush();
+ PreferenceCategory topPref = mPreferenceScreen.findPreference(TOP_OPTIONS_KEY);
+ assertThat(topPref).isNotNull();
+ var modePref = (SelectorWithWidgetPreference) topPref.getPreference(1);
+ modePref.onClick();
+ var mode = mDisplays[mDisplayIdArg].getSupportedModes()[1];
+ verify(mMockedInjector).setUserPreferredDisplayMode(mDisplayIdArg, mode);
+ }
+
+ private void initFragment() {
+ if (mFragment != null) {
+ return;
+ }
+ mFragment = new TestableResolutionPreferenceFragment();
+ mFragment.onCreateCallback(null);
+ mFragment.onActivityCreatedCallback(null);
+ mFragment.onStartCallback();
+ }
+
+ private class TestableResolutionPreferenceFragment extends ResolutionPreferenceFragment {
+ private final View mMockedRootView;
+ private final TextView mEmptyView;
+ private final Resources mMockedResources;
+ private final MetricsLogger mLogger;
+ TestableResolutionPreferenceFragment() {
+ super(mMockedInjector);
+ mMockedResources = mock(Resources.class);
+ doReturn(61).when(mMockedResources).getInteger(
+ com.android.internal.R.integer.config_externalDisplayPeakRefreshRate);
+ doReturn(1920).when(mMockedResources).getInteger(
+ com.android.internal.R.integer.config_externalDisplayPeakWidth);
+ doReturn(1080).when(mMockedResources).getInteger(
+ com.android.internal.R.integer.config_externalDisplayPeakHeight);
+ doReturn(true).when(mMockedResources).getBoolean(
+ com.android.internal.R.bool.config_refreshRateSynchronizationEnabled);
+ mMockedRootView = mock(View.class);
+ mEmptyView = new TextView(mContext);
+ doReturn(mEmptyView).when(mMockedRootView).findViewById(android.R.id.empty);
+ mLogger = mMockedMetricsLogger;
+ }
+
+ @Override
+ public PreferenceScreen getPreferenceScreen() {
+ return mPreferenceScreen;
+ }
+
+ @Override
+ public View getView() {
+ return mMockedRootView;
+ }
+
+ @Override
+ public void setEmptyView(View view) {
+ assertThat(view).isEqualTo(mEmptyView);
+ }
+
+ @Override
+ public View getEmptyView() {
+ return mEmptyView;
+ }
+
+ @Override
+ public void addPreferencesFromResource(int resource) {
+ mPreferenceIdFromResource = resource;
+ }
+
+ @Override
+ protected int getDisplayIdArg() {
+ return mDisplayIdArg;
+ }
+
+ @Override
+ protected void writePreferenceClickMetric(Preference preference) {
+ mLogger.writePreferenceClickMetric(preference);
+ }
+
+ @Override
+ @NonNull
+ protected Resources getResources(@NonNull Context context) {
+ return mMockedResources;
+ }
+ }
+
+ /**
+ * Interface allowing to mock and spy on log events.
+ */
+ public interface MetricsLogger {
+ /**
+ * On preference click metric
+ */
+ void writePreferenceClickMetric(Preference preference);
+ }
+}