chromium/chrome/android/java/src/org/chromium/chrome/browser/tab/RequestDesktopUtils.java

// Copyright 2021 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.chrome.browser.tab;

import static org.chromium.components.content_settings.PrefNames.DESKTOP_SITE_WINDOW_SETTING_ENABLED;

import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;
import android.os.Build;
import android.util.DisplayMetrics;
import android.view.Display;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.BuildInfo;
import org.chromium.base.ContextUtils;
import org.chromium.base.SysUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.build.BuildConfig;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.page_info.SiteSettingsHelper;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.components.browser_ui.site_settings.SingleCategorySettingsConstants;
import org.chromium.components.browser_ui.site_settings.SiteSettingsCategory;
import org.chromium.components.browser_ui.site_settings.WebsitePreferenceBridge;
import org.chromium.components.browser_ui.util.ConversionUtils;
import org.chromium.components.content_settings.ContentSettingValues;
import org.chromium.components.content_settings.ContentSettingsType;
import org.chromium.components.feature_engagement.EventConstants;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.components.messages.DismissReason;
import org.chromium.components.messages.MessageBannerProperties;
import org.chromium.components.messages.MessageDispatcher;
import org.chromium.components.messages.MessageIdentifier;
import org.chromium.components.messages.PrimaryActionClickBehavior;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.ukm.UkmRecorder;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.display.DisplayAndroidManager;
import org.chromium.ui.display.DisplayUtil;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.url.GURL;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Locale;

/** Utilities for requesting desktop sites support. */
public class RequestDesktopUtils {
    private static final String SITE_WILDCARD = "*";
    // Global defaults experiment constants.
    private static DisplayMetrics sDisplayMetrics;

    static final double DEFAULT_GLOBAL_SETTING_DEFAULT_ON_DISPLAY_SIZE_THRESHOLD_INCHES = 10.0;
    static final int DEFAULT_GLOBAL_SETTING_DEFAULT_ON_SMALLEST_SCREEN_WIDTH_THRESHOLD_DP = 600;
    static final int DEFAULT_GLOBAL_SETTING_DEFAULT_ON_MEMORY_LIMIT_THRESHOLD_MB = 6500;

    // Note: these values must match the UserAgentRequestType enum in enums.xml.
    @IntDef({UserAgentRequestType.REQUEST_DESKTOP, UserAgentRequestType.REQUEST_MOBILE})
    @Retention(RetentionPolicy.SOURCE)
    private @interface UserAgentRequestType {
        int REQUEST_DESKTOP = 0;
        int REQUEST_MOBILE = 1;
    }

    // Note: these values must match the DeviceOrientation2 enum in enums.xml.
    @IntDef({DeviceOrientation2.LANDSCAPE, DeviceOrientation2.PORTRAIT})
    @Retention(RetentionPolicy.SOURCE)
    private @interface DeviceOrientation2 {
        int LANDSCAPE = 0;
        int PORTRAIT = 1;
    }

    /**
     * Records the metrics associated with changing the user agent by user.
     *
     * @param isDesktop True if the user agent is the desktop.
     * @param tab The current activity {@link Tab}.
     */
    public static void recordUserChangeUserAgent(boolean isDesktop, @Nullable Tab tab) {
        RecordUserAction.record("MobileMenuRequestDesktopSite");

        RecordHistogram.recordBooleanHistogram(
                "Android.RequestDesktopSite.UserSwitchToDesktop", isDesktop);

        if (tab == null || tab.isOffTheRecord() || tab.getWebContents() == null) return;

        new UkmRecorder.Bridge()
                .recordEventWithIntegerMetric(
                        tab.getWebContents(),
                        "Android.UserRequestedUserAgentChange",
                        "UserAgentType",
                        isDesktop
                                ? UserAgentRequestType.REQUEST_DESKTOP
                                : UserAgentRequestType.REQUEST_MOBILE);
    }

    /**
     * Records the ukms associated with changing screen orientation.
     *
     * @param isLandscape True if the orientation is landscape.
     * @param tab The current activity {@link Tab}.
     */
    public static void recordScreenOrientationChangedUkm(boolean isLandscape, @Nullable Tab tab) {
        if (tab == null || tab.isOffTheRecord() || tab.getWebContents() == null) return;

        new UkmRecorder.Bridge()
                .recordEventWithIntegerMetric(
                        tab.getWebContents(),
                        "Android.ScreenRotation",
                        "TargetDeviceOrientation",
                        isLandscape ? DeviceOrientation2.LANDSCAPE : DeviceOrientation2.PORTRAIT);
    }

    /**
     * Set or remove a domain level exception with URL for {@link
     * ContentSettingsType.REQUEST_DESKTOP_SITE}. Clear the subdomain level exception if any.
     *
     * @param profile Target profile whose content settings needs to be updated.
     * @param url {@link GURL} for the site that changes in desktop user agent.
     * @param useDesktopUserAgent True if the input |url| needs to use desktop user agent.
     */
    public static void setRequestDesktopSiteContentSettingsForUrl(
            Profile profile, GURL url, boolean useDesktopUserAgent) {
        boolean isOffTheRecord = profile.isOffTheRecord();
        String domainWildcardPattern =
                WebsitePreferenceBridge.toDomainWildcardPattern(url.getSpec());
        // Clear subdomain level exception if any.
        WebsitePreferenceBridge.setContentSettingCustomScope(
                profile,
                ContentSettingsType.REQUEST_DESKTOP_SITE,
                url.getHost(),
                /* secondaryPattern= */ SITE_WILDCARD,
                ContentSettingValues.DEFAULT);

        @ContentSettingValues
        int defaultValue =
                WebsitePreferenceBridge.getDefaultContentSetting(
                        profile, ContentSettingsType.REQUEST_DESKTOP_SITE);
        assert defaultValue == ContentSettingValues.ALLOW
                || defaultValue == ContentSettingValues.BLOCK;
        boolean rdsGlobalSetting = defaultValue == ContentSettingValues.ALLOW;
        @ContentSettingValues
        int contentSettingValue =
                useDesktopUserAgent ? ContentSettingValues.ALLOW : ContentSettingValues.BLOCK;
        // For normal profile, remove domain level setting if it matches the global setting.
        // For incognito profile, keep the domain level setting to override the settings from normal
        // profile.
        if (!isOffTheRecord && useDesktopUserAgent == rdsGlobalSetting) {
            // Keep the domain settings when the window setting preference is ON.
            PrefService prefService = UserPrefs.get(profile);
            if (!prefService.getBoolean(DESKTOP_SITE_WINDOW_SETTING_ENABLED)) {
                contentSettingValue = ContentSettingValues.DEFAULT;
            }
        }

        // Set or remove a domain level exception.
        WebsitePreferenceBridge.setContentSettingCustomScope(
                profile,
                ContentSettingsType.REQUEST_DESKTOP_SITE,
                domainWildcardPattern,
                /* secondaryPattern= */ SITE_WILDCARD,
                contentSettingValue);
    }

    /**
     * Upgrade a non-default tab level RDS setting to a domain level setting when RDS exceptions is
     * supported. This method is expected to be invoked only once after support is added for domain
     * level exceptions.
     * @param tab The {@link Tab} for which the RDS setting will be upgraded.
     * @param profile The {@link Profile} used to upgrade the RDS setting.
     * @param tabUserAgent The current {@link TabUserAgent} set for the tab.
     * @param url The {@link GURL} for which a domain level exception will be added.
     */
    public static void maybeUpgradeTabLevelDesktopSiteSetting(
            Tab tab, Profile profile, @TabUserAgent int tabUserAgent, @Nullable GURL url) {
        if (url == null) {
            return;
        }

        // If the tab UA is UNSET, it represents a state before tab level settings were applied for
        // the tab, so the domain level setting cannot be upgraded to at this time.
        if (tabUserAgent == TabUserAgent.UNSET) {
            return;
        }

        RequestDesktopUtils.setRequestDesktopSiteContentSettingsForUrl(
                profile, url, tabUserAgent == TabUserAgent.DESKTOP);
        // Reset the tab level setting after upgrade.
        tab.setUserAgent(TabUserAgent.DEFAULT);
    }

    /**
     * Determines whether the desktop site global setting should be enabled by default.
     *
     * @param displaySizeInInches The device primary display size, in inches.
     * @param context The current context.
     * @return Whether the desktop site global setting should be default-enabled.
     */
    static boolean shouldDefaultEnableGlobalSetting(double displaySizeInInches, Context context) {
        // Desktop Android always requests desktop sites.
        if (BuildConfig.IS_DESKTOP_ANDROID) {
            return true;
        }

        // Do not default-enable if memory is below threshold.
        if (SysUtils.amountOfPhysicalMemoryKB()
                < DEFAULT_GLOBAL_SETTING_DEFAULT_ON_MEMORY_LIMIT_THRESHOLD_MB
                        * ConversionUtils.KILOBYTES_PER_MEGABYTE) {
            return false;
        }

        // Do not default-enable for x86 devices.
        if (!isCpuArchitectureArm()) {
            return false;
        }

        // Do not default-enable on external display.
        if (isOnExternalDisplay(context)) {
            return false;
        }

        // Do not default-enable if the screen size in inches is below threshold.
        if (displaySizeInInches < DEFAULT_GLOBAL_SETTING_DEFAULT_ON_DISPLAY_SIZE_THRESHOLD_INCHES) {
            return false;
        }

        // Do not default-enable if the smallest screen size in dp is below threshold.
        if (context.getResources().getConfiguration().smallestScreenWidthDp
                < DEFAULT_GLOBAL_SETTING_DEFAULT_ON_SMALLEST_SCREEN_WIDTH_THRESHOLD_DP) {
            return false;
        }

        // Do not default-enable if the setting has already been default enabled; or the user has
        // explicitly updated the setting.
        SharedPreferencesManager sharedPreferencesManager = ChromeSharedPreferences.getInstance();
        boolean previouslyDefaultEnabled =
                sharedPreferencesManager.readBoolean(
                        ChromePreferenceKeys.DEFAULT_ENABLED_DESKTOP_SITE_GLOBAL_SETTING, false);
        boolean previouslyUpdatedByUser =
                sharedPreferencesManager.contains(
                        SingleCategorySettingsConstants
                                .USER_ENABLED_DESKTOP_SITE_GLOBAL_SETTING_PREFERENCE_KEY);
        return !previouslyDefaultEnabled && !previouslyUpdatedByUser;
    }

    /**
     * Default-enables the desktop site global setting if {@code shouldDefaultEnableGlobalSetting}
     * returns true.
     * @param displaySizeInInches The device primary display size, in inches.
     * @param profile The current {@link Profile}.
     * @param context The current context.
     * @return Whether the desktop site global setting was default-enabled.
     */
    public static boolean maybeDefaultEnableGlobalSetting(
            double displaySizeInInches, Profile profile, Context context) {
        if (!shouldDefaultEnableGlobalSetting(displaySizeInInches, context)) {
            return false;
        }

        WebsitePreferenceBridge.setCategoryEnabled(
                profile, ContentSettingsType.REQUEST_DESKTOP_SITE, true);
        ChromeSharedPreferences.getInstance()
                .writeBoolean(
                        ChromePreferenceKeys.DEFAULT_ENABLED_DESKTOP_SITE_GLOBAL_SETTING, true);
        return true;
    }

    /**
     * Default-enables the desktop site window setting if Chrome is opened on a tablet-sized
     * internal display.
     *
     * @param activity The current {@link Activity}.
     * @param profile The current {@link Profile}.
     */
    public static void maybeDefaultEnableWindowSetting(Activity activity, Profile profile) {
        int smallestScreenWidthDp = DisplayUtil.getCurrentSmallestScreenWidth(activity);
        boolean isOnExternalDisplay = isOnExternalDisplay(activity);
        if (isOnExternalDisplay
                || smallestScreenWidthDp < DeviceFormFactor.MINIMUM_TABLET_WIDTH_DP
                || BuildConfig.IS_DESKTOP_ANDROID) {
            return;
        }
        PrefService prefService = UserPrefs.get(profile);
        if (prefService.isDefaultValuePreference(DESKTOP_SITE_WINDOW_SETTING_ENABLED)) {
            prefService.setBoolean(DESKTOP_SITE_WINDOW_SETTING_ENABLED, /* newValue= */ true);
        }
    }

    /**
     * Creates and shows a message to notify the user of a default update to the desktop site global
     * setting.
     * @param profile The current {@link Profile}.
     * @param messageDispatcher The {@link MessageDispatcher} to enqueue the message.
     * @param context The current context.
     * @return Whether the message was shown.
     */
    public static boolean maybeShowDefaultEnableGlobalSettingMessage(
            Profile profile, MessageDispatcher messageDispatcher, Context context) {
        if (messageDispatcher == null) return false;

        // Desktop devices always request desktop sites so there's no need to show a message to
        // the user.
        if (BuildConfig.IS_DESKTOP_ANDROID) {
            return false;
        }

        // Present the message only if the global setting has been default-enabled.
        if (!ChromeSharedPreferences.getInstance()
                .contains(ChromePreferenceKeys.DEFAULT_ENABLED_DESKTOP_SITE_GLOBAL_SETTING)) {
            return false;
        }

        // Since there might be a delay in triggering this message after the desktop site global
        // setting is default-enabled, it could be possible that the user subsequently disabled the
        // setting. Present the message only if the setting is enabled.
        if (!WebsitePreferenceBridge.isCategoryEnabled(
                profile, ContentSettingsType.REQUEST_DESKTOP_SITE)) {
            return false;
        }

        Tracker tracker = TrackerFactory.getTrackerForProfile(profile);
        if (!tracker.shouldTriggerHelpUI(
                FeatureConstants.REQUEST_DESKTOP_SITE_DEFAULT_ON_FEATURE)) {
            return false;
        }

        Resources resources = context.getResources();
        PropertyModel message =
                new PropertyModel.Builder(MessageBannerProperties.ALL_KEYS)
                        .with(
                                MessageBannerProperties.MESSAGE_IDENTIFIER,
                                MessageIdentifier.DESKTOP_SITE_GLOBAL_DEFAULT_OPT_OUT)
                        .with(
                                MessageBannerProperties.TITLE,
                                resources.getString(R.string.rds_global_default_on_message_title))
                        .with(
                                MessageBannerProperties.ICON_RESOURCE_ID,
                                R.drawable.ic_desktop_windows)
                        .with(
                                MessageBannerProperties.PRIMARY_BUTTON_TEXT,
                                resources.getString(R.string.rds_global_default_on_message_button))
                        .with(
                                MessageBannerProperties.ON_PRIMARY_ACTION,
                                () -> {
                                    SiteSettingsHelper.showCategorySettings(
                                            context,
                                            SiteSettingsCategory.Type.REQUEST_DESKTOP_SITE);
                                    tracker.notifyEvent(
                                            EventConstants.DESKTOP_SITE_DEFAULT_ON_PRIMARY_ACTION);
                                    return PrimaryActionClickBehavior.DISMISS_IMMEDIATELY;
                                })
                        .with(
                                MessageBannerProperties.ON_DISMISSED,
                                (dismissReason) -> {
                                    if (dismissReason == DismissReason.GESTURE) {
                                        tracker.notifyEvent(
                                                EventConstants.DESKTOP_SITE_DEFAULT_ON_GESTURE);
                                    }
                                    tracker.dismissed(
                                            FeatureConstants
                                                    .REQUEST_DESKTOP_SITE_DEFAULT_ON_FEATURE);
                                })
                        .build();

        messageDispatcher.enqueueWindowScopedMessage(message, false);
        return true;
    }

    /** Record event for feature engagement on desktop site settings page open. */
    public static void notifyRequestDesktopSiteSettingsPageOpened(Profile profile) {
        TrackerFactory.getTrackerForProfile(profile)
                .notifyEvent(EventConstants.DESKTOP_SITE_SETTINGS_PAGE_OPENED);
    }

    /**
     * Determine whether RDS window setting should be applied. When returning 'true' the mobile user
     * agent should be used for the current window size.
     */
    static boolean shouldApplyWindowSetting(Profile profile, GURL url, Context context) {
        // Skip window setting on Automotive and revisit if / when they add split screen.
        if (BuildInfo.getInstance().isAutomotive) {
            return false;
        }
        PrefService prefService = UserPrefs.get(profile);
        if (!prefService.getBoolean(DESKTOP_SITE_WINDOW_SETTING_ENABLED)) {
            return false;
        }
        if (!TabUtils.isRequestDesktopSiteContentSettingsGlobal(profile, url)) {
            return false;
        }
        // Try the window attributes width first.
        // PCCT has its width stored in window attributes.
        int widthPixels = -1;
        Activity activity = ContextUtils.activityFromContext(context);
        // activity might be null in integration tests.
        if (activity != null && activity.getWindow() != null) {
            widthPixels = activity.getWindow().getAttributes().width;
        }
        DisplayMetrics displayMetrics = RequestDesktopUtils.getDisplayMetricsFromContext(context);
        // Use width from displayMetrics if the window attributes width is invalid.
        if (widthPixels <= 0) {
            widthPixels = displayMetrics.widthPixels;
        }
        return widthPixels / displayMetrics.density < DeviceFormFactor.MINIMUM_TABLET_WIDTH_DP;
    }

    /**
     * Retrieve the {@link DisplayMetrics} from {@link Context} for the current window.
     * @param context The current {@link Context}.
     * @return The {@link DisplayMetrics} for the current window.
     */
    private static DisplayMetrics getDisplayMetricsFromContext(Context context) {
        if (sDisplayMetrics != null) {
            return sDisplayMetrics;
        }
        Display display = DisplayAndroidManager.getDefaultDisplayForContext(context);
        DisplayMetrics displayMetrics = new DisplayMetrics();
        display.getMetrics(displayMetrics);
        return displayMetrics;
    }

    @VisibleForTesting
    static void setTestDisplayMetrics(DisplayMetrics displayMetrics) {
        sDisplayMetrics = displayMetrics;
    }

    /** Check if the CPU architecture is ARM. */
    private static boolean isCpuArchitectureArm() {
        String[] abiStrings = Build.SUPPORTED_ABIS;
        if (abiStrings == null || abiStrings.length == 0) {
            return false;
        }
        return abiStrings[0].toLowerCase(Locale.ROOT).contains("arm");
    }

    private static boolean isOnExternalDisplay(Context context) {
        Display display = DisplayAndroidManager.getDefaultDisplayForContext(context);
        return display.getDisplayId() != Display.DEFAULT_DISPLAY;
    }
}