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