chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/UrlBarMediator.java

// Copyright 2018 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.omnibox;

import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.text.Spanned;
import android.text.TextUtils;
import android.view.View;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.chrome.browser.omnibox.UrlBar.ScrollType;
import org.chromium.chrome.browser.omnibox.UrlBarCoordinator.SelectionState;
import org.chromium.chrome.browser.omnibox.UrlBarProperties.AutocompleteText;
import org.chromium.chrome.browser.omnibox.UrlBarProperties.UrlBarTextState;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.ui.theme.BrandedColorScheme;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.omnibox.OmniboxUrlEmphasizer.UrlEmphasisSpan;
import org.chromium.ui.modelutil.PropertyModel;

/** Handles collecting and pushing state information to the UrlBar model. */
class UrlBarMediator implements UrlBar.UrlBarTextContextMenuDelegate {
    private final @NonNull Context mContext;
    private final @NonNull PropertyModel mModel;
    private final @NonNull Callback<Boolean> mOnFocusChangeCallback;

    private boolean mHasFocus;

    private @NonNull UrlBarData mUrlBarData = UrlBarData.EMPTY;
    private @ScrollType int mScrollType = UrlBar.ScrollType.NO_SCROLL;
    private @SelectionState int mSelectionState = UrlBarCoordinator.SelectionState.SELECT_ALL;

    private int mPreviousBrandedColorScheme;
    // For NTP, when in un-focus state, the search text hint color is fixed for the real search box
    // and we couldn't change it by the branded color scheme.
    private boolean mIsHintTextFixedForNtp;

    /**
     * Creates a URLBarMediator.
     *
     * @param context The current Android's context.
     * @param model MVC property model to write changes to.
     * @param focusChangeCallback The callback that will be notified when focus changes on the
     *     UrlBar.
     */
    public UrlBarMediator(
            @NonNull Context context,
            @NonNull PropertyModel model,
            @NonNull Callback<Boolean> focusChangeCallback) {
        mContext = context;
        mModel = model;
        mOnFocusChangeCallback = focusChangeCallback;

        mModel.set(UrlBarProperties.FOCUS_CHANGE_CALLBACK, this::onUrlFocusChange);
        mModel.set(UrlBarProperties.SHOW_CURSOR, false);
        mModel.set(UrlBarProperties.TEXT_CONTEXT_MENU_DELEGATE, this);
        mModel.set(UrlBarProperties.HAS_URL_SUGGESTIONS, false);
        setBrandedColorScheme(BrandedColorScheme.APP_DEFAULT);
        pushTextToModel();
    }

    public void destroy() {
        mModel.set(UrlBarProperties.FOCUS_CHANGE_CALLBACK, null);
        mModel.set(UrlBarProperties.TEXT_CONTEXT_MENU_DELEGATE, null);
        mModel.set(UrlBarProperties.TEXT_CHANGE_LISTENER, null);
    }

    /** Sets a listener for url text changes. */
    public void setTextChangeListener(Callback<String> listener) {
        mModel.set(UrlBarProperties.TEXT_CHANGE_LISTENER, listener);
    }

    /**
     * Sets a listener for url key events. See the {@link
     * UrlBarCoordinator#setKeyDownListener(View.OnKeyListener)}.
     */
    public void setKeyDownListener(View.OnKeyListener listener) {
        mModel.set(UrlBarProperties.KEY_DOWN_LISTENER, listener);
    }

    /**
     * Sets a listener called when user input begins. See the {@link
     * UrlBarCoordinator#setTypingStartedListener(Runnable)}.
     */
    public void setTypingStartedListener(Runnable listener) {
        mModel.set(UrlBarProperties.TYPING_STARTED_LISTENER, listener);
    }

    /**
     * Updates the text content of the UrlBar.
     *
     * @param data The new data to be displayed.
     * @param scrollType The scroll type that should be applied to the data.
     * @param selectionState Specifies how the text should be selected when focused.
     * @return Whether this data differs from the previously passed in values.
     */
    public boolean setUrlBarData(
            @NonNull UrlBarData data,
            @ScrollType int scrollType,
            @SelectionState int selectionState) {
        assert data != null;

        if (data.originEndIndex == data.originStartIndex) {
            scrollType = UrlBar.ScrollType.SCROLL_TO_BEGINNING;
        }

        // Do not scroll to the end of the host for URLs such as data:, javascript:, etc...
        if (data.url != null
                && data.displayText != null
                && data.originEndIndex == data.displayText.length()) {
            String scheme = data.url.getScheme();
            if (!TextUtils.isEmpty(scheme) && !UrlBarData.SCHEMES_TO_SPLIT.contains(scheme)) {
                scrollType = UrlBar.ScrollType.SCROLL_TO_BEGINNING;
            }
        }

        if (!mHasFocus
                && isNewTextEquivalentToExistingText(mUrlBarData, data)
                && mScrollType == scrollType) {
            return false;
        }
        mUrlBarData = data;
        mScrollType = scrollType;
        mSelectionState = selectionState;

        pushTextToModel();
        return true;
    }

    @NonNull
    UrlBarData getUrlBarData() {
        return mUrlBarData;
    }

    private void pushTextToModel() {
        CharSequence text =
                !mHasFocus ? mUrlBarData.displayText : mUrlBarData.getEditingOrDisplayText();
        CharSequence textForAutofillServices = text;

        if (!(mHasFocus || TextUtils.isEmpty(text) || mUrlBarData.url == null)) {
            textForAutofillServices = mUrlBarData.url.getSpec();
        }

        @ScrollType int scrollType = mHasFocus ? UrlBar.ScrollType.NO_SCROLL : mScrollType;
        if (text == null) text = "";

        UrlBarTextState state =
                new UrlBarTextState(
                        text,
                        textForAutofillServices,
                        scrollType,
                        mUrlBarData.originEndIndex,
                        mSelectionState);
        mModel.set(UrlBarProperties.TEXT_STATE, state);
    }

    @VisibleForTesting
    protected static boolean isNewTextEquivalentToExistingText(
            UrlBarData existingUrlData, UrlBarData newUrlData) {
        if (existingUrlData == null) return newUrlData == null;
        if (newUrlData == null) return false;

        if (!TextUtils.equals(existingUrlData.editingText, newUrlData.editingText)) return false;

        CharSequence existingCharSequence = existingUrlData.displayText;
        CharSequence newCharSequence = newUrlData.displayText;
        if (existingCharSequence == null) return newCharSequence == null;

        // Regardless of focus state, ensure the text content is the same.
        if (!TextUtils.equals(existingCharSequence, newCharSequence)) return false;

        // If both existing and new text is empty, then treat them equal regardless of their
        // spanned state.
        if (TextUtils.isEmpty(newCharSequence)) return true;

        // When not focused, compare the emphasis spans applied to the text to determine
        // equality.  Internally, TextView applies many additional spans that need to be
        // ignored for this comparison to be useful, so this is scoped to only the span types
        // applied by our UI.
        if (!(newCharSequence instanceof Spanned) || !(existingCharSequence instanceof Spanned)) {
            return false;
        }

        Spanned currentText = (Spanned) existingCharSequence;
        Spanned newText = (Spanned) newCharSequence;
        UrlEmphasisSpan[] currentSpans =
                currentText.getSpans(0, currentText.length(), UrlEmphasisSpan.class);
        UrlEmphasisSpan[] newSpans = newText.getSpans(0, newText.length(), UrlEmphasisSpan.class);
        if (currentSpans.length != newSpans.length) return false;
        for (int i = 0; i < currentSpans.length; i++) {
            UrlEmphasisSpan currentSpan = currentSpans[i];
            UrlEmphasisSpan newSpan = newSpans[i];
            if (!currentSpan.equals(newSpan)
                    || currentText.getSpanStart(currentSpan) != newText.getSpanStart(newSpan)
                    || currentText.getSpanEnd(currentSpan) != newText.getSpanEnd(newSpan)
                    || currentText.getSpanFlags(currentSpan) != newText.getSpanFlags(newSpan)) {
                return false;
            }
        }
        return true;
    }

    /**
     * Sets the autocomplete text to be shown.
     *
     * @param userText The existing user text.
     * @param autocompleteText The text to be appended to the user text.
     * @param additionalText This string is displayed adjacent to the omnibox if this match is the
     *     default. Will usually be URL when autocompleting a title, and empty otherwise.
     */
    public void setAutocompleteText(
            @NonNull String userText,
            @Nullable String autocompleteText,
            @Nullable String additionalText) {
        if (!mHasFocus) {
            assert false : "Should not update autocomplete text when not focused";
            return;
        }
        mModel.set(
                UrlBarProperties.AUTOCOMPLETE_TEXT,
                new AutocompleteText(userText, autocompleteText, additionalText));
    }

    private void onUrlFocusChange(boolean focus) {
        mHasFocus = focus;

        if (mModel.get(UrlBarProperties.ALLOW_FOCUS)) {
            mModel.set(UrlBarProperties.SHOW_CURSOR, mHasFocus);
        }

        UrlBarTextState preCallbackState = mModel.get(UrlBarProperties.TEXT_STATE);
        mOnFocusChangeCallback.onResult(focus);
        boolean textChangedInFocusCallback =
                mModel.get(UrlBarProperties.TEXT_STATE) != preCallbackState;
        if (!textChangedInFocusCallback) {
            pushTextToModel();
        }
    }

    /**
     * Sets the color scheme.
     *
     * @param brandedColorScheme The {@link @BrandedColorScheme}.
     * @return Whether this resulted in a change from the previous value.
     */
    public boolean setBrandedColorScheme(@BrandedColorScheme int brandedColorScheme) {
        // TODO(bauerb): Make clients observe the property instead of checking the return value.
        final @ColorInt int textColor =
                OmniboxResourceProvider.getUrlBarPrimaryTextColor(mContext, brandedColorScheme);
        final @ColorInt int hintTextColor =
                OmniboxResourceProvider.getUrlBarHintTextColor(mContext, brandedColorScheme);

        mModel.set(UrlBarProperties.TEXT_COLOR, textColor);
        if (!mIsHintTextFixedForNtp) {
            mModel.set(UrlBarProperties.HINT_TEXT_COLOR, hintTextColor);
        }

        boolean isBrandedColorSchemeChanged = mPreviousBrandedColorScheme != brandedColorScheme;
        mPreviousBrandedColorScheme = brandedColorScheme;
        return isBrandedColorSchemeChanged;
    }

    /**
     * Sets whether to use incognito colors.
     *
     * @param incognitoColorsEnabled Whether to use incognito colors.
     */
    public void setIncognitoColorsEnabled(boolean incognitoColorsEnabled) {
        mModel.set(UrlBarProperties.INCOGNITO_COLORS_ENABLED, incognitoColorsEnabled);
    }

    /** Sets whether the view allows user focus. */
    public void setAllowFocus(boolean allowFocus) {
        mModel.set(UrlBarProperties.ALLOW_FOCUS, allowFocus);
        if (allowFocus) {
            mModel.set(UrlBarProperties.SHOW_CURSOR, mHasFocus);
        }
    }

    /** Set the listener to be notified for URL direction changes. */
    public void setUrlDirectionListener(Callback<Integer> listener) {
        mModel.set(UrlBarProperties.URL_DIRECTION_LISTENER, listener);
    }

    @Override
    public String getReplacementCutCopyText(
            String currentText, int selectionStart, int selectionEnd) {
        if (mUrlBarData.url == null) return null;

        // Replace the cut/copy text only applies if the user selected from the beginning of the
        // display text.
        if (selectionStart != 0) return null;

        // Trim to just the currently selected text as that is the only text we are replacing.
        currentText = currentText.substring(selectionStart, selectionEnd);

        String formattedUrlLocation;
        String originalUrlLocation;

        formattedUrlLocation =
                getUrlContentsPrePath(
                        mUrlBarData.getEditingOrDisplayText().toString(),
                        mUrlBarData.url.getHost());
        originalUrlLocation =
                getUrlContentsPrePath(mUrlBarData.url.getSpec(), mUrlBarData.url.getHost());

        // If we are copying/cutting the full previously formatted URL, reset the URL
        // text before initiating the TextViews handling of the context menu.
        //
        // Example:
        //    Original display text: www.example.com
        //    Original URL:          http://www.example.com
        //
        // Editing State:
        //    www.example.com/blah/foo
        //    |<--- Selection --->|
        //
        // Resulting clipboard text should be:
        //    http://www.example.com/blah/
        //
        // As long as the full original text was selected, it will replace that with the original
        // URL and keep any further modifications by the user.
        if (!currentText.startsWith(formattedUrlLocation)
                || selectionEnd < formattedUrlLocation.length()) {
            return null;
        }

        return originalUrlLocation + currentText.substring(formattedUrlLocation.length());
    }

    @Override
    public String getTextToPaste() {
        Context context = ContextUtils.getApplicationContext();

        ClipboardManager clipboard =
                (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
        ClipData clipData = clipboard.getPrimaryClip();
        if (clipData == null) return null;

        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < clipData.getItemCount(); i++) {
            builder.append(clipData.getItemAt(i).coerceToText(context));
        }

        String stringToPaste = sanitizeTextForPaste(builder.toString());
        return stringToPaste;
    }

    /**
     * @param hasSuggestions Whether suggestions are showing in the URL bar.
     */
    public void onUrlBarSuggestionsChanged(boolean hasSuggestions) {
        mModel.set(UrlBarProperties.HAS_URL_SUGGESTIONS, hasSuggestions);
    }

    @VisibleForTesting
    protected String sanitizeTextForPaste(String text) {
        return OmniboxViewUtil.sanitizeTextForPaste(text);
    }

    /**
     * Returns the portion of the URL that precedes the path/query section of the URL.
     *
     * @param url The url to be used to find the preceding portion.
     * @param host The host to be located in the URL to determine the location of the path.
     * @return The URL contents that precede the path (or the passed in URL if the host is not
     *     found).
     */
    private static String getUrlContentsPrePath(String url, String host) {
        int hostIndex = url.indexOf(host);
        if (hostIndex == -1) return url;

        int pathIndex = url.indexOf('/', hostIndex);
        if (pathIndex <= 0) return url;

        return url.substring(0, pathIndex);
    }

    /**
     * Sets search box hint text color to brandedColorScheme.
     *
     * @param brandedColorScheme The {@link @BrandedColorScheme}.
     */
    void setUrlBarHintTextColorForDefault(@BrandedColorScheme int brandedColorScheme) {
        mIsHintTextFixedForNtp = false;
        setBrandedColorScheme(brandedColorScheme);
    }

    /** Sets search box hint text color to be colorOnSurface for NTP's un-focus state. */
    void setUrlBarHintTextColorForNtp() {
        mIsHintTextFixedForNtp = true;
        final @ColorInt int hintTextColor = SemanticColorUtils.getDefaultTextColor(mContext);
        mModel.set(UrlBarProperties.HINT_TEXT_COLOR, hintTextColor);
    }
}