// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.components.browser_ui.accessibility;
import static org.chromium.content_public.browser.HostZoomMap.AVAILABLE_ZOOM_FACTORS;
import static org.chromium.content_public.browser.HostZoomMap.TEXT_SIZE_MULTIPLIER_RATIO;
import static org.chromium.content_public.browser.HostZoomMap.getSystemFontScale;
import org.chromium.base.ContextUtils;
import org.chromium.base.MathUtils;
import org.chromium.content_public.browser.BrowserContextHandle;
import org.chromium.content_public.browser.ContentFeatureList;
import org.chromium.content_public.browser.ContentFeatureMap;
import org.chromium.content_public.browser.HostZoomMap;
import java.util.Arrays;
/**
* General purpose utils class for page zoom feature. This is for methods that are shared by both
* the settings UI and the MVC component (e.g. shared prefs calls), and is accessed by each
* individually rather than having the settings UI depend on the MVC component.
*
* The zoom of a page is calculated internally with a base an exponent. The base is set to
* |kTextSizeMultiplierRatio| = 1.2. See: third_party/blink/common/page/page_zoom.cc.
* E.g. To get a zoom level of 50%, internally the number -3.8 is used, because: 1.2^-3.8 = 0.50.
*
* To help with confusion, we will consistently stick to the following verbiage:
*
* "zoom factor" = the internal number used by HostZoomMap, acts as the exponent. (double)
* "zoom level" = the percentage for the zoom that is presented externally to the user. (double)
* "zoom string" = the string that is actually presented to the user for zoom percentage. (String)
* "zoom seek value" = an arbitrary int to map the factor to an integer value for a SeekBar. (int)
*
* For example, some common zoom values are:
*
* string factor level seek value
* 50% | -3.8 | 0.50 | 0 |
* 100% | 0.0 | 1.00 | 50 |
* 250% | 5.03 | 2.50 | 200 |
* 300% | 6.03 | 3.00 | 250 |
*
*/
public class PageZoomUtils {
// The default value for zoom that user can change in the accessibility settings page.
public static final int PAGE_ZOOM_DEFAULT_SEEK_VALUE = convertZoomFactorToSeekBarValue(0.0);
// The max value for the seek bar to help with rounding effects (not shown to user).
public static final int PAGE_ZOOM_MAXIMUM_SEEKBAR_VALUE = 250;
// The max value for the text size contrast seek bar, used in Smart Zoom feature.
public static final int TEXT_SIZE_CONTRAST_MAX_LEVEL = 100;
// The minimum and maximum zoom values as a percentage (e.g. 50% = 0.50, 300% = 3.0).
protected static final float PAGE_ZOOM_MINIMUM_ZOOM_LEVEL = 0.50f;
protected static final float PAGE_ZOOM_MAXIMUM_ZOOM_LEVEL = 3.00f;
// The timeout for when to dismiss the slider from the last user interaction
protected static final long LAST_INTERACTION_DISMISSAL = 5000; // 5 seconds = 5 * 1000
// The range of user-readable zoom values at which the seek bar should snap to the
// default zoom value, (e.g. 0.03 = range of +/- 3%).
private static final double DEFAULT_ZOOM_LEVEL_SNAP_RANGE = 0.03;
/**
* Seekbars have values 0 to 100 by default. For simplicity, we will keep these values and
* convert to the correct zoom factor under-the-hood. See comment at top of class.
*
* @param newValue int value of the seekbar.
* @return double
*/
public static double convertSeekBarValueToZoomFactor(int newValue) {
// Zoom levels are from |PAGE_ZOOM_MINIMUM_ZOOM_LEVEL| to |PAGE_ZOOM_MAXIMUM_ZOOM_LEVEL|,
// and these should map linearly to the seekbar's 0 - 100 range.
float seekbarPercent = (float) newValue / PAGE_ZOOM_MAXIMUM_SEEKBAR_VALUE;
float chosenZoomLevel =
PAGE_ZOOM_MINIMUM_ZOOM_LEVEL
+ ((PAGE_ZOOM_MAXIMUM_ZOOM_LEVEL - PAGE_ZOOM_MINIMUM_ZOOM_LEVEL)
* seekbarPercent);
// The zoom level maps internally to a zoom factor, which is the exponent that
// |kTextSizeMultiplierRatio| = 1.2 is raised to. For example, 1.2^-3.8 = 0.50, or
// 1.2^3.8 = 2.0. See: third_party/blink/common/page/page_zoom.cc
// This means zoomFactor = log_base1.2(chosenZoomLevel). Java has natural log and base
// 10, we can rewrite the above as: log10(chosenZoomLevel) / log10(1.2);
double result = Math.log10(chosenZoomLevel) / Math.log10(TEXT_SIZE_MULTIPLIER_RATIO);
return MathUtils.roundTwoDecimalPlaces(result);
}
/**
* This method does the reverse of the above method.
* Seekbars have values 0 to 100 by default. For simplicity, we will keep these values and
* convert to the correct zoom factor under-the-hood.
*
* @param zoomFactor zoom factor to get seek bar value for.
* @return int
*/
public static int convertZoomFactorToSeekBarValue(double zoomFactor) {
// To get to a seekbar value from an index, raise the base (1.2) to the given |zoomFactor|
// exponent to get the zoom level. Find where this level sits proportionately between the
// min and max level, and use that percentage as the corresponding seek value.
double zoomLevel = convertZoomFactorToZoomLevel(zoomFactor);
double zoomLevelPercent =
(double) (zoomLevel - PAGE_ZOOM_MINIMUM_ZOOM_LEVEL)
/ (PAGE_ZOOM_MAXIMUM_ZOOM_LEVEL - PAGE_ZOOM_MINIMUM_ZOOM_LEVEL);
return (int) Math.round(PAGE_ZOOM_MAXIMUM_SEEKBAR_VALUE * zoomLevelPercent);
}
/**
* This method converts the seekbar value to a zoom level so that the level can be displayed
* to the user in a human-readable format, e.g. 1.0, 1.50.
* @param newValue seek bar value to convert to zoom level
* @return double
*/
public static double convertSeekBarValueToZoomLevel(int newValue) {
return PAGE_ZOOM_MINIMUM_ZOOM_LEVEL
+ ((PAGE_ZOOM_MAXIMUM_ZOOM_LEVEL - PAGE_ZOOM_MINIMUM_ZOOM_LEVEL)
* ((float) newValue / PAGE_ZOOM_MAXIMUM_SEEKBAR_VALUE));
}
/**
* This method converts the zoom factor to a zoom level in a human-readable format,
* e.g. 1.0, 1.50.
*
* @param zoomFactor zoom factor to get zoom level for.
* @return double
*/
public static double convertZoomFactorToZoomLevel(double zoomFactor) {
// To get the zoom level from the zoom factor, raise the base (1.2) to the given
// |zoomFactor| exponent to get the zoom level.
return Math.pow(TEXT_SIZE_MULTIPLIER_RATIO, zoomFactor);
}
/**
* Returns true if the given seek bar value falls within the range at which
* the seek bar should be snapped to the default global zoom level. Returns false otherwise.
* @param seekBarValue the seek bar value.
* @param defaultZoomFactor the default zoom factor to compare against.
* @return boolean
*/
public static boolean shouldSnapSeekBarValueToDefaultZoom(
int seekBarValue, double defaultZoomFactor) {
double currentZoomLevel = convertSeekBarValueToZoomLevel(seekBarValue);
double defaultZoomLevel = convertZoomFactorToZoomLevel(defaultZoomFactor);
return (MathUtils.roundTwoDecimalPlaces(Math.abs(currentZoomLevel - defaultZoomLevel)))
<= PageZoomUtils.DEFAULT_ZOOM_LEVEL_SNAP_RANGE;
}
/**
* Set a new user choice for default zoom level given a SeekBar value.
* This is part of the Profile and is set in Desktop through Settings > Appearance.
* @param newValue int The new zoom by seek bar value
*/
public static void setDefaultZoomBySeekBarValue(BrowserContextHandle context, int newValue) {
setDefaultZoomLevel(context, convertSeekBarValueToZoomFactor(newValue));
}
/**
* Returns the current user choice for default zoom level as a seek bar value.
* This is part of the Profile and is set in Desktop through Settings > Appearance.
* @return int
*/
public static int getDefaultZoomAsSeekBarValue(BrowserContextHandle context) {
return convertZoomFactorToSeekBarValue(getDefaultZoomLevel(context));
}
/**
* Returns the current user choice for default zoom level as a zoom factor.
* This is part of the Profile and is set in Desktop through Settings > Appearance.
* @return double
*/
public static double getDefaultZoomLevelAsZoomFactor(BrowserContextHandle context) {
return getDefaultZoomLevel(context);
}
// Methods to interact with SharedPreferences. These do not use SharedPreferencesManager so
// that they can be used in //components.
/**
* Returns true if the user has set a choice for always showing the Zoom AppMenu
* item (set in Accessibility Settings). This setting is Chrome Android specific.
* @return boolean
*/
public static boolean hasUserSetShouldAlwaysShowZoomMenuItemOption() {
return ContextUtils.getAppSharedPreferences()
.contains(AccessibilityConstants.PAGE_ZOOM_ALWAYS_SHOW_MENU_ITEM);
}
/**
* Returns the current user setting for always showing the Zoom AppMenu
* item (set in Accessibility Settings). Default is false. This setting is Chrome Android
* specific.
* @return boolean
*/
public static boolean shouldAlwaysShowZoomMenuItem() {
return ContextUtils.getAppSharedPreferences()
.getBoolean(AccessibilityConstants.PAGE_ZOOM_ALWAYS_SHOW_MENU_ITEM, false);
}
/**
* Returns true if the Zoom AppMenu item should be shown, false otherwise.
*
* - If there is a current user choice set in Accessibility Settings, respect and return the
* user setting.
* - Otherwise, if there is an OS level font size set, return true.
* - Otherwise, return false.
*
* This setting is Chrome Android specific.
* @return boolean
*/
public static boolean shouldShowZoomMenuItem() {
if (!ContentFeatureMap.isEnabled(ContentFeatureList.ACCESSIBILITY_PAGE_ZOOM)) {
return false;
}
// Always respect the user's choice if the user has set this in Accessibility Settings.
if (hasUserSetShouldAlwaysShowZoomMenuItemOption()) {
if (shouldAlwaysShowZoomMenuItem()) {
PageZoomUma.logAppMenuEnabledStateHistogram(
PageZoomUma.AccessibilityPageZoomAppMenuEnabledState.USER_ENABLED);
return true;
} else {
PageZoomUma.logAppMenuEnabledStateHistogram(
PageZoomUma.AccessibilityPageZoomAppMenuEnabledState.USER_DISABLED);
return false;
}
}
// The default (float) |fontScale| is 1, the default page zoom is 1.
// If the user has a system font scale other than the default, always show the menu item.
boolean isUsingDefaultSystemFontScale = MathUtils.areFloatsEqual(getSystemFontScale(), 1f);
if (!isUsingDefaultSystemFontScale && HostZoomMap.shouldAdjustForOSLevel()) {
PageZoomUma.logAppMenuEnabledStateHistogram(
PageZoomUma.AccessibilityPageZoomAppMenuEnabledState.OS_ENABLED);
return true;
}
PageZoomUma.logAppMenuEnabledStateHistogram(
PageZoomUma.AccessibilityPageZoomAppMenuEnabledState.NOT_ENABLED);
return false;
}
/**
* Set a new user choice for always showing the Zoom AppMenu item. This setting is Chrome
* Android specific.
* @param newValue boolean
*/
public static void setShouldAlwaysShowZoomMenuItem(boolean newValue) {
ContextUtils.getAppSharedPreferences()
.edit()
.putBoolean(AccessibilityConstants.PAGE_ZOOM_ALWAYS_SHOW_MENU_ITEM, newValue)
.apply();
}
/**
* Returns true is the user has set a choice for whether OS adjustment should be made in zoom
* calculation. This setting is Chrome Android specific.
*
* @return boolean
*/
public static boolean hasUserSetIncludeOSAdjustmentOption() {
assert ContentFeatureMap.isEnabled(ContentFeatureList.ACCESSIBILITY_PAGE_ZOOM_ENHANCEMENTS)
: "hasUserSetIncludeOSAdjustmentOption should only be called if the flag is"
+ " enabled.";
return ContextUtils.getAppSharedPreferences()
.contains(AccessibilityConstants.PAGE_ZOOM_INCLUDE_OS_ADJUSTMENT);
}
/**
* Returns true is Page Zoom should include an OS level adjustment to zoom level. If no value
* has been set by the user, return the current value of the feature param.
*
* @return boolean
*/
public static boolean shouldIncludeOSAdjustment() {
assert ContentFeatureMap.isEnabled(ContentFeatureList.ACCESSIBILITY_PAGE_ZOOM_ENHANCEMENTS)
: "shouldIncludeOSAdjustment should only be called if the flag is enabled.";
return ContextUtils.getAppSharedPreferences()
.getBoolean(
AccessibilityConstants.PAGE_ZOOM_INCLUDE_OS_ADJUSTMENT,
HostZoomMap.shouldAdjustForOSLevel());
}
/**
* Set a new user choice for including an OS level adjustment in zoom level calculation.
*
* @param newValue boolean
*/
public static void setShouldIncludeOSAdjustment(boolean newValue) {
assert ContentFeatureMap.isEnabled(ContentFeatureList.ACCESSIBILITY_PAGE_ZOOM_ENHANCEMENTS)
: "setShouldIncludeOSAdjustment should only be called if the flag is enabled.";
ContextUtils.getAppSharedPreferences()
.edit()
.putBoolean(AccessibilityConstants.PAGE_ZOOM_INCLUDE_OS_ADJUSTMENT, newValue)
.apply();
}
/**
* Get the index of the next closest zoom factor in the cached available values in the given
* direction from the current zoom factor.
* Current zoom factor must be within range of possible zoom factors.
* @param decrease boolean True if the next index should be decreasing from the current,
* false otherwise
* @param currentZoomFactor double The current zoom factor for which to search
* @throws IllegalArgumentException if current zoom factor is <= the smallest cached zoom factor
* or >= the largest cached zoom factor
* @return int The index of the next closest zoom factor
*/
public static int getNextIndex(boolean decrease, double currentZoomFactor) {
// Assert valid current zoom factor
if (decrease && currentZoomFactor <= AVAILABLE_ZOOM_FACTORS[0]) {
throw new IllegalArgumentException(
"currentZoomFactor should be greater than " + AVAILABLE_ZOOM_FACTORS[0]);
} else if (!decrease
&& currentZoomFactor >= AVAILABLE_ZOOM_FACTORS[AVAILABLE_ZOOM_FACTORS.length - 1]) {
throw new IllegalArgumentException(
"currentZoomFactor should be less than "
+ AVAILABLE_ZOOM_FACTORS[AVAILABLE_ZOOM_FACTORS.length - 1]);
}
// BinarySearch will return the index of the first value equal to the given value.
// Otherwise it will return (-(insertion point) - 1).
// If a negative value is returned, then add one and negate to get the insertion point.
int index = Arrays.binarySearch(AVAILABLE_ZOOM_FACTORS, currentZoomFactor);
// If the value is found, index will be >=0 and we will decrement/increment accordingly:
if (index >= 0) {
if (decrease) {
--index;
} else {
++index;
}
}
// If the value is not found, index will be (-(index) - 1), so negate and add one:
if (index < 0) {
index = ++index * -1;
// Index will now be the first index above the current value, so in the case of
// decreasing zoom, we will decrement once.
if (decrease) --index;
}
return index;
}
// Methods that interact with Prefs.
private static void setDefaultZoomLevel(
BrowserContextHandle context, double newDefaultZoomLevel) {
HostZoomMap.setDefaultZoomLevel(context, newDefaultZoomLevel);
}
private static double getDefaultZoomLevel(BrowserContextHandle context) {
return HostZoomMap.getDefaultZoomLevel(context);
}
}