chromium/chrome/browser/privacy/java/src/org/chromium/chrome/browser/privacy/secure_dns/SecureDnsProviderPreference.java

// Copyright 2020 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.privacy.secure_dns;

import android.content.Context;
import android.text.Editable;
import android.text.Html;
import android.text.InputType;
import android.text.TextWatcher;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.RadioGroup;
import android.widget.Spinner;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;

import com.google.android.material.textfield.TextInputLayout;

import org.chromium.chrome.browser.privacy.secure_dns.SecureDnsBridge.Entry;
import org.chromium.components.browser_ui.widget.RadioButtonWithDescription;
import org.chromium.components.browser_ui.widget.RadioButtonWithDescriptionLayout;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * SecureDnsProviderPreference is the user interface that is shown when Secure DNS is enabled.
 * When Secure DNS is disabled, the SecureDnsProviderPreference is hidden.
 */
class SecureDnsProviderPreference extends Preference
        implements RadioGroup.OnCheckedChangeListener,
                AdapterView.OnItemSelectedListener,
                TextWatcher {
    // UI strings, loaded from the context.
    private final String mPrivacyTemplate;
    private final String mInvalidWarning;
    private final String mProbeWarning;

    // Server menu entries.
    private final List<Entry> mOptions;

    // UI elements.  These fields are assigned only once, in onBindViewHolder.
    private RadioButtonWithDescriptionLayout mGroup;
    private RadioButtonWithDescription mAutomaticButton;
    private RadioButtonWithDescription mSecureButton;
    private Spinner mServerMenu;
    private TextView mPrivacyPolicy;
    private EditText mCustomServer;
    private TextInputLayout mCustomServerLayout;

    // All variable UI state for SecureDnsProviderPreference is encapsulated in this field.
    // To ensure that the UI is updated whenever the state changes, this field
    // should only be modified by setState().
    private State mState;

    // Checks whether the current template is actually reachable, and updates
    // mCustomServerLayout's error state.
    private final Runnable mProbeRunner = this::startServerProbe;

    /**
     * State is an immutable representation of the control's current UI state.  It can represent
     * states that are invalid, which are required when editing the template or changing modes.
     */
    static class State {
        // Indicates that secure mode is selected.
        public final boolean secure;
        // The selected or entered DoH template(s), if any.
        public final @NonNull String config;
        // Whether the selected template is valid.
        public final boolean valid;

        State(boolean secure, @NonNull String config, boolean valid) {
            this.secure = secure;
            this.config = config;
            this.valid = valid;
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof State) {
                State other = (State) obj;
                return other.secure == secure
                        && other.config.equals(config)
                        && other.valid == valid;
            }
            return false;
        }

        @Override
        public int hashCode() {
            // This method is not used, but is defined here for consistency with equals().
            return toString().hashCode();
        }

        State withSecure(boolean secure) {
            return new State(secure, config, valid);
        }

        State withConfig(@NonNull String config) {
            return new State(secure, config, valid);
        }

        State withValid(boolean valid) {
            return new State(secure, config, valid);
        }

        @Override
        public @NonNull String toString() {
            return String.format("State(%b, %s, %b)", secure, config, valid);
        }
    }

    public SecureDnsProviderPreference(Context context, AttributeSet attrs) {
        super(context, attrs);
        // Inflating from XML.
        setLayoutResource(R.layout.secure_dns_provider_preference);

        // Preload strings from disk.
        mPrivacyTemplate = context.getString(R.string.settings_secure_dropdown_mode_privacy_policy);
        mInvalidWarning = context.getString(R.string.settings_secure_dns_custom_format_error);
        mProbeWarning = context.getString(R.string.settings_secure_dns_custom_connection_error);
        mOptions = makeOptions(context);
    }

    private static List<Entry> makeOptions(Context context) {
        List<Entry> entries = SecureDnsBridge.getProviders();

        // The Spinner's options consist of an entry called "Custom", followed
        // by the providers in random order.
        List<Entry> options = new ArrayList<>(entries.size() + 1);
        String customEntryName = context.getString(R.string.settings_custom);
        options.add(new Entry(customEntryName, "", ""));
        Collections.shuffle(entries);
        options.addAll(entries);

        return options;
    }

    @Override
    public void onBindViewHolder(PreferenceViewHolder holder) {
        super.onBindViewHolder(holder);
        mGroup = (RadioButtonWithDescriptionLayout) holder.findViewById(R.id.mode_group);
        mGroup.setOnCheckedChangeListener(this);
        mAutomaticButton = (RadioButtonWithDescription) holder.findViewById(R.id.automatic);
        mSecureButton = (RadioButtonWithDescription) holder.findViewById(R.id.secure);

        View selectionContainer = holder.findViewById(R.id.selection_container);
        mServerMenu = selectionContainer.findViewById(R.id.dropdown_spinner);
        mServerMenu.setOnItemSelectedListener(this);
        Context context = selectionContainer.getContext();
        ArrayAdapter<Entry> adapter =
                new ArrayAdapter<>(context, R.layout.secure_dns_provider_spinner_item, mOptions);
        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
        mServerMenu.setAdapter(adapter);

        mPrivacyPolicy = selectionContainer.findViewById(R.id.privacy_policy);
        mPrivacyPolicy.setMovementMethod(LinkMovementMethod.getInstance());
        mCustomServer = selectionContainer.findViewById(R.id.custom_server);
        mCustomServer.addTextChangedListener(this);
        // Show an action button instead of a carriage-return key.
        mCustomServer.setRawInputType(InputType.TYPE_TEXT_VARIATION_URI);
        mCustomServerLayout = selectionContainer.findViewById(R.id.custom_server_layout);

        mGroup.attachAccessoryView(selectionContainer, mSecureButton);
        updateView();
    }

    void setState(State state) {
        if (!state.equals(mState)) {
            mState = state;
            updateView();
        }
    }

    State getState() {
        return mState;
    }

    // Returns the index of the dropdown entry that matches the current template,
    // or 0 if none match (i.e. a custom template).
    private int matchingDropdownIndex() {
        for (int i = 1; i < mServerMenu.getCount(); ++i) {
            Entry entry = (Entry) mServerMenu.getItemAtPosition(i);
            if (entry.config.equals(mState.config)) {
                return i;
            }
        }
        return 0;
    }

    /** Updates the view to match mState. */
    private void updateView() {
        if (mGroup == null) {
            // Not yet bound to view holder.
            return;
        }

        if (mSecureButton.isChecked() != mState.secure) {
            mSecureButton.setChecked(mState.secure);
        }
        boolean automaticMode = !mState.secure;
        if (mAutomaticButton.isChecked() != automaticMode) {
            mAutomaticButton.setChecked(automaticMode);
        }

        int position = matchingDropdownIndex();
        if (mServerMenu.getSelectedItemPosition() != position) {
            mServerMenu.setSelection(position);
        }

        if (mState.secure) {
            mServerMenu.setVisibility(View.VISIBLE);
            // Position 0 is the custom server.  Other positions are actual server entries.
            if (position > 0) {
                // Selected server mode.
                Entry entry = (Entry) mServerMenu.getSelectedItem();
                String html = mPrivacyTemplate.replace("$1", entry.privacy);
                mPrivacyPolicy.setText(Html.fromHtml(html));

                mPrivacyPolicy.setVisibility(View.VISIBLE);
                mCustomServerLayout.setVisibility(View.GONE);
            } else {
                // Custom server mode.
                if (!mCustomServer.getText().toString().equals(mState.config)) {
                    mCustomServer.setText(mState.config);
                    mCustomServer.removeCallbacks(mProbeRunner);
                    if (mState.secure) {
                        mCustomServer.requestFocus();

                        // If the custom server field is idle for one second, run a probe.
                        // Any changes to the field will cancel this probe and start another.
                        mCustomServer.postDelayed(mProbeRunner, 1000);
                    }
                }

                // Show a warning if the input is invalid and is not the start of a valid URL.
                boolean showWarning = !mState.valid && !"https://".startsWith(mState.config);
                mCustomServerLayout.setError(showWarning ? mInvalidWarning : null);

                mCustomServerLayout.setVisibility(View.VISIBLE);
                mPrivacyPolicy.setVisibility(View.GONE);
            }
        } else {
            mServerMenu.setVisibility(View.GONE);
            mPrivacyPolicy.setVisibility(View.GONE);
            mCustomServerLayout.setVisibility(View.GONE);
        }

        SecureDnsBridge.updateValidationHistogram(mState.valid);
    }

    private void startServerProbe() {
        String group = mState.config;
        if (group.isEmpty() || !mState.valid || !mState.secure) {
            return;
        }
        // probeConfig() is a blocking network call that uses WaitableEvent, so it cannot run
        // on the UI thread, nor via the Java PostTask bindings, which do not expose
        // base::WithBaseSyncPrimitives.  Instead, it runs on a fresh Java thread.
        new Thread(
                        () -> {
                            if (SecureDnsBridge.probeConfig(group)) {
                                return;
                            }
                            mCustomServer.post(
                                    () -> { // Send the state change back to the UI thread.
                                        // Check that the setting hasn't been changed.
                                        if (mState.config.contentEquals(group)) {
                                            mCustomServerLayout.setError(mProbeWarning);
                                        }
                                    });
                        })
                .start();
    }

    @Override
    public void onCheckedChanged(RadioGroup group, int checkedId) {
        boolean secure = checkedId == R.id.secure;
        if (mState.secure != secure) {
            tryUpdate(mState.withSecure(secure));
        }
    }

    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
        int oldPos = matchingDropdownIndex();
        if (oldPos == pos) {
            // This is the same item that was already in effect.  Ignore spurious event.
            // This check is required to avoid overwriting the custom template, because
            // attaching an adapter triggers a spurious onItemSelected event.
            return;
        }
        Entry entry = (Entry) parent.getItemAtPosition(pos);
        tryUpdate(mState.withConfig(entry.config));
    }

    @Override
    public void onNothingSelected(AdapterView<?> parent) {
        // In this UI, one radio button is always selected.
    }

    private void tryUpdate(State newState) {
        if (callChangeListener(newState)) {
            setState(newState);
        } else {
            updateView();
        }
    }

    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {}

    @Override
    public void onTextChanged(CharSequence s, int start, int count, int after) {}

    @Override
    public void afterTextChanged(Editable s) {
        tryUpdate(mState.withConfig(s.toString()));

        mCustomServer.removeCallbacks(mProbeRunner);
        mCustomServer.postDelayed(mProbeRunner, 1000);
    }
}