blob: c069513463c71fab9bf905ffa803c7d46a9098d7 [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// 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.net.Uri;
import android.text.Editable;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.format.DateUtils;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.omnibox.UrlBar.ScrollType;
import org.chromium.chrome.browser.omnibox.UrlBar.UrlTextChangeListener;
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.suggestions.AutocompleteCoordinator;
import org.chromium.components.omnibox.OmniboxUrlEmphasizer.UrlEmphasisSpan;
import org.chromium.content_public.browser.BrowserStartupController;
import org.chromium.ui.base.Clipboard;
import org.chromium.ui.modelutil.PropertyModel;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
/**
* Handles collecting and pushing state information to the UrlBar model.
*/
class UrlBarMediator
implements UrlBar.UrlBarTextContextMenuDelegate, UrlBar.UrlTextChangeListener, TextWatcher {
private final PropertyModel mModel;
private Callback<Boolean> mOnFocusChangeCallback;
private boolean mHasFocus;
private UrlBarData mUrlBarData;
private @ScrollType int mScrollType = UrlBar.ScrollType.NO_SCROLL;
private @SelectionState int mSelectionState = UrlBarCoordinator.SelectionState.SELECT_ALL;
// The numbers for "MobileOmnibox.LongPressPasteAge", the expected time range of time is from
// 1ms to 1 hour, and 100 buckets.
private static final long MIN_TIME_MILLIS = 1;
private static final long MAX_TIME_MILLIS = DateUtils.HOUR_IN_MILLIS;
private static final int NUM_OF_BUCKETS = 100;
private final List<UrlTextChangeListener> mUrlTextChangeListeners = new ArrayList<>();
private final List<TextWatcher> mTextChangedListeners = new ArrayList<>();
/**
* Creates a URLBarMediator.
*
* @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 PropertyModel model, @NonNull Callback<Boolean> focusChangeCallback) {
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.URL_TEXT_CHANGE_LISTENER, this);
mModel.set(UrlBarProperties.TEXT_CHANGED_LISTENER, this);
setUseDarkTextColors(true);
}
public void destroy() {
mUrlTextChangeListeners.clear();
mTextChangedListeners.clear();
mOnFocusChangeCallback = (unused) -> {};
}
/** Adds a listener for url text changes. */
public void addUrlTextChangeListener(UrlTextChangeListener listener) {
mUrlTextChangeListeners.add(listener);
}
/** @see android.widget.TextView#addTextChangedListener */
public void addTextChangedListener(TextWatcher textWatcher) {
mTextChangedListeners.add(textWatcher);
}
/**
* 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(
UrlBarData data, @ScrollType int scrollType, @SelectionState int selectionState) {
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.originEndIndex == data.displayText.length()) {
Uri uri = Uri.parse(data.url);
String scheme = uri.getScheme();
if (!TextUtils.isEmpty(scheme)
&& UrlBarData.UNSUPPORTED_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;
}
private void pushTextToModel() {
CharSequence text =
!mHasFocus ? mUrlBarData.displayText : mUrlBarData.getEditingOrDisplayText();
CharSequence textForAutofillServices = text;
if (!(mHasFocus || TextUtils.isEmpty(text) || mUrlBarData.url == null)) {
textForAutofillServices = mUrlBarData.url;
}
@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.
*/
public void setAutocompleteText(String userText, String autocompleteText) {
if (!mHasFocus) {
assert false : "Should not update autocomplete text when not focused";
return;
}
mModel.set(UrlBarProperties.AUTOCOMPLETE_TEXT,
new AutocompleteText(userText, autocompleteText));
}
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 (mUrlBarData != null && !textChangedInFocusCallback) {
pushTextToModel();
}
}
/**
* Sets whether to use dark text colors.
*
* @return Whether this resulted in a change from the previous value.
*/
public boolean setUseDarkTextColors(boolean useDarkColors) {
// TODO(bauerb): Make clients observe the property instead of checking the return value.
boolean previousValue = mModel.get(UrlBarProperties.USE_DARK_TEXT_COLORS);
mModel.set(UrlBarProperties.USE_DARK_TEXT_COLORS, useDarkColors);
return previousValue != useDarkColors;
}
/**
* 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 == null || 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;
try {
// TODO(bauerb): Use |urlBarData.originEndIndex| for this instead?
URL javaUrl = new URL(mUrlBarData.url);
formattedUrlLocation = getUrlContentsPrePath(
mUrlBarData.getEditingOrDisplayText().toString(), javaUrl.getHost());
originalUrlLocation = getUrlContentsPrePath(mUrlBarData.url, javaUrl.getHost());
} catch (MalformedURLException mue) {
// Just keep the existing selected text for cut/copy if unable to parse the URL.
return null;
}
// 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());
recordPasteMetrics(stringToPaste);
return stringToPaste;
}
@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);
}
/** @see UrlTextChangeListener */
@Override
public void onTextChanged(String textWithoutAutocomplete, String textWithAutocomplete) {
for (int i = 0; i < mUrlTextChangeListeners.size(); i++) {
mUrlTextChangeListeners.get(i).onTextChanged(
textWithoutAutocomplete, textWithAutocomplete);
}
}
/** @see TextWatcher */
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
for (int i = 0; i < mTextChangedListeners.size(); i++) {
mTextChangedListeners.get(i).beforeTextChanged(s, start, count, after);
}
}
/** @see TextWatcher */
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
for (int i = 0; i < mTextChangedListeners.size(); i++) {
mTextChangedListeners.get(i).onTextChanged(s, start, before, count);
}
}
/** @see TextWatcher */
@Override
public void afterTextChanged(Editable editable) {
for (int i = 0; i < mTextChangedListeners.size(); i++) {
mTextChangedListeners.get(i).afterTextChanged(editable);
}
}
private void recordPasteMetrics(String text) {
boolean isUrl = BrowserStartupController.getInstance().isFullBrowserStarted()
&& AutocompleteCoordinator.qualifyPartialURLQuery(text) != null;
long age = System.currentTimeMillis() - Clipboard.getInstance().getLastModifiedTimeMs();
RecordHistogram.recordCustomTimesHistogram("MobileOmnibox.LongPressPasteAge", age,
MIN_TIME_MILLIS, MAX_TIME_MILLIS, NUM_OF_BUCKETS);
if (isUrl) {
RecordHistogram.recordCustomTimesHistogram("MobileOmnibox.LongPressPasteAge.URL", age,
MIN_TIME_MILLIS, MAX_TIME_MILLIS, NUM_OF_BUCKETS);
} else {
RecordHistogram.recordCustomTimesHistogram("MobileOmnibox.LongPressPasteAge.TEXT", age,
MIN_TIME_MILLIS, MAX_TIME_MILLIS, NUM_OF_BUCKETS);
}
}
}