1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.settings.connecteddevice.display; 18 19 import static android.view.Display.INVALID_DISPLAY; 20 21 import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.DISPLAY_ID_ARG; 22 import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.EXTERNAL_DISPLAY_HELP_URL; 23 import static com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.EXTERNAL_DISPLAY_NOT_FOUND_RESOURCE; 24 25 import android.app.settings.SettingsEnums; 26 import android.content.Context; 27 import android.content.res.Resources; 28 import android.os.Bundle; 29 import android.util.Log; 30 import android.util.Pair; 31 import android.view.Display.Mode; 32 import android.view.View; 33 import android.widget.TextView; 34 35 import androidx.annotation.NonNull; 36 import androidx.annotation.Nullable; 37 import androidx.annotation.VisibleForTesting; 38 import androidx.preference.PreferenceCategory; 39 import androidx.preference.PreferenceGroup; 40 import androidx.preference.PreferenceScreen; 41 42 import com.android.internal.util.ToBooleanFunction; 43 import com.android.settings.R; 44 import com.android.settings.SettingsPreferenceFragmentBase; 45 import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.DisplayListener; 46 import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.Injector; 47 import com.android.settingslib.widget.SelectorWithWidgetPreference; 48 49 import java.util.ArrayList; 50 import java.util.HashSet; 51 import java.util.List; 52 53 public class ResolutionPreferenceFragment extends SettingsPreferenceFragmentBase { 54 private static final String TAG = "ResolutionPreference"; 55 static final int DEFAULT_LOW_REFRESH_RATE = 60; 56 static final String MORE_OPTIONS_KEY = "more_options"; 57 static final String TOP_OPTIONS_KEY = "top_options"; 58 static final int MORE_OPTIONS_TITLE_RESOURCE = 59 R.string.external_display_more_options_title; 60 static final int EXTERNAL_DISPLAY_RESOLUTION_SETTINGS_RESOURCE = 61 R.xml.external_display_resolution_settings; 62 static final String DISPLAY_MODE_LIMIT_OVERRIDE_PROP = "persist.sys.com.android.server.display" 63 + ".feature.flags.enable_mode_limit_for_external_display-override"; 64 @Nullable 65 private Injector mInjector; 66 @Nullable 67 private PreferenceCategory mTopOptionsPreference; 68 @Nullable 69 private PreferenceCategory mMoreOptionsPreference; 70 private boolean mStarted; 71 private final HashSet<String> mResolutionPreferences = new HashSet<>(); 72 private int mExternalDisplayPeakWidth; 73 private int mExternalDisplayPeakHeight; 74 private int mExternalDisplayPeakRefreshRate; 75 private boolean mRefreshRateSynchronizationEnabled; 76 private boolean mMoreOptionsExpanded; 77 private final Runnable mUpdateRunnable = this::update; 78 private final DisplayListener mListener = new DisplayListener() { 79 @Override 80 public void update(int displayId) { 81 scheduleUpdate(); 82 } 83 }; 84 85 @Override getMetricsCategory()86 public int getMetricsCategory() { 87 return SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY; 88 } 89 90 @Override getHelpResource()91 public int getHelpResource() { 92 return EXTERNAL_DISPLAY_HELP_URL; 93 } 94 95 @Override onCreateCallback(@ullable Bundle icicle)96 public void onCreateCallback(@Nullable Bundle icicle) { 97 if (mInjector == null) { 98 mInjector = new Injector(getPrefContext()); 99 } 100 addPreferencesFromResource(EXTERNAL_DISPLAY_RESOLUTION_SETTINGS_RESOURCE); 101 updateDisplayModeLimits(mInjector.getContext()); 102 } 103 104 @Override onActivityCreatedCallback(@ullable Bundle savedInstanceState)105 public void onActivityCreatedCallback(@Nullable Bundle savedInstanceState) { 106 View view = getView(); 107 TextView emptyView = null; 108 if (view != null) { 109 emptyView = (TextView) view.findViewById(android.R.id.empty); 110 } 111 if (emptyView != null) { 112 emptyView.setText(EXTERNAL_DISPLAY_NOT_FOUND_RESOURCE); 113 setEmptyView(emptyView); 114 } 115 } 116 117 @Override onStartCallback()118 public void onStartCallback() { 119 mStarted = true; 120 if (mInjector == null) { 121 return; 122 } 123 mInjector.registerDisplayListener(mListener); 124 scheduleUpdate(); 125 } 126 127 @Override onStopCallback()128 public void onStopCallback() { 129 mStarted = false; 130 if (mInjector == null) { 131 return; 132 } 133 mInjector.unregisterDisplayListener(mListener); 134 unscheduleUpdate(); 135 } 136 ResolutionPreferenceFragment()137 public ResolutionPreferenceFragment() {} 138 139 @VisibleForTesting ResolutionPreferenceFragment(@onNull Injector injector)140 ResolutionPreferenceFragment(@NonNull Injector injector) { 141 mInjector = injector; 142 } 143 144 @VisibleForTesting getDisplayIdArg()145 protected int getDisplayIdArg() { 146 var args = getArguments(); 147 return args != null ? args.getInt(DISPLAY_ID_ARG, INVALID_DISPLAY) : INVALID_DISPLAY; 148 } 149 150 @VisibleForTesting 151 @NonNull getResources(@onNull Context context)152 protected Resources getResources(@NonNull Context context) { 153 return context.getResources(); 154 } 155 update()156 private void update() { 157 final PreferenceScreen screen = getPreferenceScreen(); 158 if (screen == null || mInjector == null) { 159 return; 160 } 161 var context = mInjector.getContext(); 162 if (context == null) { 163 return; 164 } 165 var display = mInjector.getDisplay(getDisplayIdArg()); 166 if (display == null) { 167 screen.removeAll(); 168 mTopOptionsPreference = null; 169 mMoreOptionsPreference = null; 170 return; 171 } 172 mResolutionPreferences.clear(); 173 var remainingModes = addModePreferences(context, 174 getTopPreference(context, screen), 175 display.getSupportedModes(), this::isTopMode, display); 176 addRemainingPreferences(context, 177 getMorePreference(context, screen), 178 display, remainingModes.first, remainingModes.second); 179 } 180 getTopPreference(@onNull Context context, @NonNull PreferenceScreen screen)181 private PreferenceCategory getTopPreference(@NonNull Context context, 182 @NonNull PreferenceScreen screen) { 183 if (mTopOptionsPreference == null) { 184 mTopOptionsPreference = new PreferenceCategory(context); 185 mTopOptionsPreference.setPersistent(false); 186 mTopOptionsPreference.setKey(TOP_OPTIONS_KEY); 187 screen.addPreference(mTopOptionsPreference); 188 } else { 189 mTopOptionsPreference.removeAll(); 190 } 191 return mTopOptionsPreference; 192 } 193 getMorePreference(@onNull Context context, @NonNull PreferenceScreen screen)194 private PreferenceCategory getMorePreference(@NonNull Context context, 195 @NonNull PreferenceScreen screen) { 196 if (mMoreOptionsPreference == null) { 197 mMoreOptionsPreference = new PreferenceCategory(context); 198 mMoreOptionsPreference.setPersistent(false); 199 mMoreOptionsPreference.setTitle(MORE_OPTIONS_TITLE_RESOURCE); 200 mMoreOptionsPreference.setOnExpandButtonClickListener(() -> { 201 mMoreOptionsExpanded = true; 202 }); 203 mMoreOptionsPreference.setKey(MORE_OPTIONS_KEY); 204 screen.addPreference(mMoreOptionsPreference); 205 } else { 206 mMoreOptionsPreference.removeAll(); 207 } 208 return mMoreOptionsPreference; 209 } 210 addRemainingPreferences(@onNull Context context, @NonNull PreferenceCategory group, @NonNull DisplayDevice display, boolean isSelectedModeFound, @NonNull List<Mode> moreModes)211 private void addRemainingPreferences(@NonNull Context context, 212 @NonNull PreferenceCategory group, @NonNull DisplayDevice display, 213 boolean isSelectedModeFound, @NonNull List<Mode> moreModes) { 214 if (moreModes.isEmpty()) { 215 return; 216 } 217 mMoreOptionsExpanded |= !isSelectedModeFound; 218 group.setInitialExpandedChildrenCount(mMoreOptionsExpanded ? Integer.MAX_VALUE : 0); 219 addModePreferences(context, group, moreModes, /*checkMode=*/ null, display); 220 } 221 addModePreferences(@onNull Context context, @NonNull PreferenceGroup group, @NonNull List<Mode> modes, @Nullable ToBooleanFunction<Mode> checkMode, @NonNull DisplayDevice display)222 private Pair<Boolean, List<Mode>> addModePreferences(@NonNull Context context, 223 @NonNull PreferenceGroup group, 224 @NonNull List<Mode> modes, 225 @Nullable ToBooleanFunction<Mode> checkMode, 226 @NonNull DisplayDevice display) { 227 Mode curMode = display.getMode(); 228 var currentResolution = curMode.getPhysicalWidth() + "x" + curMode.getPhysicalHeight(); 229 var rotatedResolution = curMode.getPhysicalHeight() + "x" + curMode.getPhysicalWidth(); 230 var skippedModes = new ArrayList<Mode>(); 231 var isAnyOfModesSelected = false; 232 for (var mode : modes) { 233 var modeStr = mode.getPhysicalWidth() + "x" + mode.getPhysicalHeight(); 234 SelectorWithWidgetPreference pref = group.findPreference(modeStr); 235 if (pref != null) { 236 continue; 237 } 238 if (checkMode != null && !checkMode.apply(mode)) { 239 skippedModes.add(mode); 240 continue; 241 } 242 var isCurrentMode = 243 currentResolution.equals(modeStr) || rotatedResolution.equals(modeStr); 244 if (!isCurrentMode && !isAllowedMode(mode)) { 245 continue; 246 } 247 if (mResolutionPreferences.contains(modeStr)) { 248 // Added to "Top modes" already. 249 continue; 250 } 251 mResolutionPreferences.add(modeStr); 252 pref = new SelectorWithWidgetPreference(context); 253 pref.setPersistent(false); 254 pref.setKey(modeStr); 255 pref.setTitle(mode.getPhysicalWidth() + " x " + mode.getPhysicalHeight()); 256 pref.setSingleLineTitle(true); 257 pref.setOnClickListener(preference -> onDisplayModeClicked(preference, display)); 258 pref.setChecked(isCurrentMode); 259 isAnyOfModesSelected |= isCurrentMode; 260 group.addPreference(pref); 261 } 262 return new Pair<>(isAnyOfModesSelected, skippedModes); 263 } 264 isTopMode(@onNull Mode mode)265 private boolean isTopMode(@NonNull Mode mode) { 266 return mTopOptionsPreference != null 267 && mTopOptionsPreference.getPreferenceCount() < 3; 268 } 269 isAllowedMode(@onNull Mode mode)270 private boolean isAllowedMode(@NonNull Mode mode) { 271 if (mRefreshRateSynchronizationEnabled 272 && (mode.getRefreshRate() < DEFAULT_LOW_REFRESH_RATE - 1 273 || mode.getRefreshRate() > DEFAULT_LOW_REFRESH_RATE + 1)) { 274 Log.d(TAG, mode + " refresh rate is out of synchronization range"); 275 return false; 276 } 277 if (mExternalDisplayPeakHeight > 0 278 && mode.getPhysicalHeight() > mExternalDisplayPeakHeight) { 279 Log.d(TAG, mode + " height is above the allowed limit"); 280 return false; 281 } 282 if (mExternalDisplayPeakWidth > 0 283 && mode.getPhysicalWidth() > mExternalDisplayPeakWidth) { 284 Log.d(TAG, mode + " width is above the allowed limit"); 285 return false; 286 } 287 if (mExternalDisplayPeakRefreshRate > 0 288 && mode.getRefreshRate() > mExternalDisplayPeakRefreshRate) { 289 Log.d(TAG, mode + " refresh rate is above the allowed limit"); 290 return false; 291 } 292 return true; 293 } 294 scheduleUpdate()295 private void scheduleUpdate() { 296 if (mInjector == null || !mStarted) { 297 return; 298 } 299 unscheduleUpdate(); 300 mInjector.getHandler().post(mUpdateRunnable); 301 } 302 unscheduleUpdate()303 private void unscheduleUpdate() { 304 if (mInjector == null || !mStarted) { 305 return; 306 } 307 mInjector.getHandler().removeCallbacks(mUpdateRunnable); 308 } 309 onDisplayModeClicked(@onNull SelectorWithWidgetPreference preference, @NonNull DisplayDevice display)310 private void onDisplayModeClicked(@NonNull SelectorWithWidgetPreference preference, 311 @NonNull DisplayDevice display) { 312 if (mInjector == null) { 313 return; 314 } 315 String[] modeResolution = preference.getKey().split("x"); 316 int width = Integer.parseInt(modeResolution[0]); 317 int height = Integer.parseInt(modeResolution[1]); 318 for (var mode : display.getSupportedModes()) { 319 if (mode.getPhysicalWidth() == width && mode.getPhysicalHeight() == height 320 && isAllowedMode(mode)) { 321 mInjector.setUserPreferredDisplayMode(display.getId(), mode); 322 return; 323 } 324 } 325 } 326 isDisplayResolutionLimitEnabled()327 private boolean isDisplayResolutionLimitEnabled() { 328 if (mInjector == null) { 329 return false; 330 } 331 var flagOverride = mInjector.getSystemProperty(DISPLAY_MODE_LIMIT_OVERRIDE_PROP); 332 var isOverrideEnabled = "true".equals(flagOverride); 333 var isOverrideEnabledOrNotSet = !"false".equals(flagOverride); 334 return (mInjector.isModeLimitForExternalDisplayEnabled() && isOverrideEnabledOrNotSet) 335 || isOverrideEnabled; 336 } 337 updateDisplayModeLimits(@ullable Context context)338 private void updateDisplayModeLimits(@Nullable Context context) { 339 if (context == null) { 340 return; 341 } 342 mExternalDisplayPeakRefreshRate = getResources(context).getInteger( 343 com.android.internal.R.integer.config_externalDisplayPeakRefreshRate); 344 if (isDisplayResolutionLimitEnabled()) { 345 mExternalDisplayPeakWidth = getResources(context).getInteger( 346 com.android.internal.R.integer.config_externalDisplayPeakWidth); 347 mExternalDisplayPeakHeight = getResources(context).getInteger( 348 com.android.internal.R.integer.config_externalDisplayPeakHeight); 349 } 350 mRefreshRateSynchronizationEnabled = getResources(context).getBoolean( 351 com.android.internal.R.bool.config_refreshRateSynchronizationEnabled); 352 Log.d(TAG, "mExternalDisplayPeakRefreshRate=" + mExternalDisplayPeakRefreshRate); 353 Log.d(TAG, "mExternalDisplayPeakWidth=" + mExternalDisplayPeakWidth); 354 Log.d(TAG, "mExternalDisplayPeakHeight=" + mExternalDisplayPeakHeight); 355 Log.d(TAG, "mRefreshRateSynchronizationEnabled=" + mRefreshRateSynchronizationEnabled); 356 } 357 } 358