chromium/components/browser_ui/site_settings/android/java/src/org/chromium/components/browser_ui/site_settings/AddExceptionPreference.java

// Copyright 2015 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.site_settings;

import static org.chromium.components.browser_ui.site_settings.WebsitePreferenceBridge.SITE_WILDCARD;

import android.content.Context;
import android.content.DialogInterface;
import android.content.res.Resources;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.Vibrator;
import android.provider.Settings;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.browser_ui.widget.CheckBoxWithDescription;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.text.EmptyTextWatcher;

/** A utility class for the UI recording exceptions to the blocked list for site settings. */
public class AddExceptionPreference extends Preference
        implements Preference.OnPreferenceClickListener {
    // The callback to notify when the user adds a site.
    private SiteAddedCallback mSiteAddedCallback;

    // The accent color to use for the icon and title view.
    private int mPrefAccentColor;

    // The custom message to show in the dialog.
    private String mDialogMessage;

    // The Site Settings Category of the exception we are adding.
    private final SiteSettingsCategory mCategory;

    // The colors for the site URL EditText
    private int mErrorColor;
    private int mDefaultColor;

    /** An interface to implement to get a callback when a site exception needs to be added. */
    public interface SiteAddedCallback {
        /**
         * The callback for the site exception that needs to be added.
         *
         * @param primaryPattern The primary pattern for the exception, usually the hostname to add,
         *     or the wildcard indicating all hosts
         * @param secondaryPattern The secondary pattern for the exception, indicating on which
         *     sites the primary pattern is affected. Usually the wildcard or a specific host (for
         *     third-party cookies).
         */
        public void onAddSite(String primaryPattern, String secondaryPattern);
    }

    /**
     * Construct a AddException preference.
     *
     * @param context The current context.
     * @param key The key to use for the preference.
     * @param message The custom message to show in the dialog.
     * @param callback A callback to receive notifications that an exception has been added.
     */
    public AddExceptionPreference(
            Context context,
            String key,
            String message,
            SiteSettingsCategory category,
            SiteAddedCallback callback) {
        super(context);
        mDialogMessage = message;
        mCategory = category;
        mSiteAddedCallback = callback;
        setOnPreferenceClickListener(this);

        setKey(key);
        Resources resources = context.getResources();
        mPrefAccentColor = SemanticColorUtils.getDefaultControlColorActive(context);
        mErrorColor = context.getColor(R.color.default_red);
        mDefaultColor =
                AppCompatResources.getColorStateList(context, R.color.default_text_color_list)
                        .getDefaultColor();

        Drawable plusIcon = ApiCompatibilityUtils.getDrawable(resources, R.drawable.plus);
        plusIcon.mutate();
        plusIcon.setColorFilter(mPrefAccentColor, PorterDuff.Mode.SRC_IN);
        setIcon(plusIcon);

        setTitle(resources.getString(R.string.website_settings_add_site));
    }

    @Override
    public void onBindViewHolder(PreferenceViewHolder holder) {
        super.onBindViewHolder(holder);
        TextView titleView = (TextView) holder.findViewById(android.R.id.title);
        titleView.setTextColor(mPrefAccentColor);
    }

    @Override
    public boolean onPreferenceClick(Preference preference) {
        showAddExceptionDialog();
        return true;
    }

    /** Show the dialog allowing the user to add a new website as an exception. */
    private void showAddExceptionDialog() {
        LayoutInflater inflater =
                (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View view = inflater.inflate(R.layout.add_site_dialog, null);
        final EditText input = view.findViewById(R.id.site);
        final CheckBoxWithDescription checkBox = view.findViewById(R.id.add_site_dialog_checkbox);

        if (mCategory.getType() == SiteSettingsCategory.Type.REQUEST_DESKTOP_SITE) {
            // Default to domain level setting for Request Desktop Site.
            checkBox.setChecked(true);
            checkBox.setVisibility(View.VISIBLE);
            int primary = R.string.website_settings_domain_desktop_site_exception_checkbox_primary;
            int description =
                    R.string.website_settings_domain_desktop_site_exception_checkbox_description;
            checkBox.setPrimaryText(getContext().getString(primary));
            checkBox.setDescriptionText(getContext().getString(description));
        }

        DialogInterface.OnClickListener onClickListener =
                new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int button) {
                        if (button == AlertDialog.BUTTON_POSITIVE) {
                            int categoryType = mCategory.getType();
                            boolean isChecked = checkBox.isChecked();
                            String pattern = input.getText().toString().trim();
                            pattern = updatePatternIfNeeded(pattern, categoryType, isChecked);
                            String primary = getPrimaryPattern(pattern, categoryType, isChecked);
                            String secondary =
                                    getSecondaryPattern(pattern, categoryType, isChecked);
                            mSiteAddedCallback.onAddSite(primary, secondary);
                        } else {
                            dialog.dismiss();
                        }
                    }
                };

        AlertDialog.Builder alert =
                new AlertDialog.Builder(getContext(), R.style.ThemeOverlay_BrowserUI_AlertDialog);
        AlertDialog alertDialog =
                alert.setTitle(R.string.website_settings_add_site_dialog_title)
                        .setMessage(mDialogMessage)
                        .setView(view)
                        .setPositiveButton(
                                R.string.website_settings_add_site_add_button, onClickListener)
                        .setNegativeButton(R.string.cancel, onClickListener)
                        .create();
        alertDialog.getDelegate().setHandleNativeActionModesEnabled(false);
        alertDialog.setOnShowListener(
                new DialogInterface.OnShowListener() {
                    @Override
                    public void onShow(DialogInterface dialog) {
                        KeyboardVisibilityDelegate.getInstance().showKeyboard(input);
                    }
                });
        alertDialog.show();
        final Button okButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE);
        okButton.setEnabled(false);

        input.addTextChangedListener(
                new EmptyTextWatcher() {
                    @Override
                    public void onTextChanged(CharSequence s, int start, int before, int count) {
                        // The intent is to capture a url pattern and register it as an exception.
                        // But a pattern can be used to express things that are not supported, such
                        // as domains, schemes and ports. Therefore we need to filter out invalid
                        // values before passing them on to the validity checker for patterns.
                        String pattern = s.toString().trim();
                        boolean isValid = isPatternValid(pattern, mCategory.getType());

                        // Vibrate when adding characters only, not when deleting them.
                        if (!isValid && count != 0) {
                            if (Settings.System.getInt(
                                            getContext().getContentResolver(),
                                            Settings.System.HAPTIC_FEEDBACK_ENABLED,
                                            1)
                                    == 1) {
                                ((Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE))
                                        .vibrate(50);
                            }
                        }

                        okButton.setEnabled(isValid && pattern.length() > 0);
                        input.setTextColor(isValid ? mDefaultColor : mErrorColor);
                    }
                });
    }

    @VisibleForTesting
    static String updatePatternIfNeeded(@NonNull String pattern, int type, boolean isChecked) {
        if (type == SiteSettingsCategory.Type.REQUEST_DESKTOP_SITE) {
            if (isChecked) {
                return WebsitePreferenceBridge.toDomainWildcardPattern(pattern);
            } else {
                return WebsitePreferenceBridge.toHostOnlyPattern(pattern);
            }
        }
        return pattern;
    }

    @VisibleForTesting
    static String getPrimaryPattern(@NonNull String pattern, int type, boolean isChecked) {
        if (type == SiteSettingsCategory.Type.THIRD_PARTY_COOKIES) {
            return SITE_WILDCARD;
        }
        return pattern;
    }

    @VisibleForTesting
    static String getSecondaryPattern(@NonNull String pattern, int type, boolean isChecked) {
        if (type == SiteSettingsCategory.Type.THIRD_PARTY_COOKIES) {
            return pattern;
        }
        return SITE_WILDCARD;
    }

    @VisibleForTesting
    static boolean isPatternValid(@NonNull String pattern, int type) {
        if (pattern.length() == 0) {
            return true;
        }
        if (pattern.contains(":") && !isColonAllowed(type)) {
            return false;
        }
        if (pattern.contains(" ") || pattern.startsWith(".")) {
            return false;
        }
        return WebsitePreferenceBridgeJni.get().isContentSettingsPatternValid(pattern);
    }

    private static boolean isColonAllowed(int type) {
        return type == SiteSettingsCategory.Type.REQUEST_DESKTOP_SITE;
    }
}