1 // Copyright 2018 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.chrome.browser.omnibox;
6 
7 import android.content.ClipData;
8 import android.content.ClipboardManager;
9 import android.content.Context;
10 import android.net.Uri;
11 import android.text.Editable;
12 import android.text.Spanned;
13 import android.text.TextUtils;
14 import android.text.TextWatcher;
15 import android.text.format.DateUtils;
16 import android.view.ActionMode;
17 
18 import androidx.annotation.VisibleForTesting;
19 
20 import org.chromium.base.Callback;
21 import org.chromium.base.ContextUtils;
22 import org.chromium.base.metrics.RecordHistogram;
23 import org.chromium.chrome.browser.WindowDelegate;
24 import org.chromium.chrome.browser.omnibox.UrlBar.ScrollType;
25 import org.chromium.chrome.browser.omnibox.UrlBar.UrlBarDelegate;
26 import org.chromium.chrome.browser.omnibox.UrlBar.UrlDirectionListener;
27 import org.chromium.chrome.browser.omnibox.UrlBar.UrlTextChangeListener;
28 import org.chromium.chrome.browser.omnibox.UrlBarCoordinator.SelectionState;
29 import org.chromium.chrome.browser.omnibox.UrlBarProperties.AutocompleteText;
30 import org.chromium.chrome.browser.omnibox.UrlBarProperties.UrlBarTextState;
31 import org.chromium.chrome.browser.omnibox.suggestions.AutocompleteCoordinator;
32 import org.chromium.components.omnibox.OmniboxUrlEmphasizer.UrlEmphasisSpan;
33 import org.chromium.content_public.browser.BrowserStartupController;
34 import org.chromium.ui.base.Clipboard;
35 import org.chromium.ui.modelutil.PropertyModel;
36 
37 import java.net.MalformedURLException;
38 import java.net.URL;
39 import java.util.ArrayList;
40 import java.util.List;
41 
42 /**
43  * Handles collecting and pushing state information to the UrlBar model.
44  */
45 class UrlBarMediator
46         implements UrlBar.UrlBarTextContextMenuDelegate, UrlBar.UrlTextChangeListener, TextWatcher {
47     private final PropertyModel mModel;
48 
49     private Callback<Boolean> mOnFocusChangeCallback;
50     private boolean mHasFocus;
51 
52     private UrlBarData mUrlBarData;
53     private @ScrollType int mScrollType = UrlBar.ScrollType.NO_SCROLL;
54     private @SelectionState int mSelectionState = UrlBarCoordinator.SelectionState.SELECT_ALL;
55 
56     // The numbers for "MobileOmnibox.LongPressPasteAge", the expected time range of time is from
57     // 1ms to 1 hour, and 100 buckets.
58     private static final long MIN_TIME_MILLIS = 1;
59     private static final long MAX_TIME_MILLIS = DateUtils.HOUR_IN_MILLIS;
60     private static final int NUM_OF_BUCKETS = 100;
61 
62     private final List<UrlTextChangeListener> mUrlTextChangeListeners = new ArrayList<>();
63     private final List<TextWatcher> mTextChangedListeners = new ArrayList<>();
64 
UrlBarMediator(PropertyModel model)65     public UrlBarMediator(PropertyModel model) {
66         mModel = model;
67 
68         mModel.set(UrlBarProperties.FOCUS_CHANGE_CALLBACK, this::onUrlFocusChange);
69         mModel.set(UrlBarProperties.SHOW_CURSOR, false);
70         mModel.set(UrlBarProperties.TEXT_CONTEXT_MENU_DELEGATE, this);
71         mModel.set(UrlBarProperties.URL_TEXT_CHANGE_LISTENER, this);
72         mModel.set(UrlBarProperties.TEXT_CHANGED_LISTENER, this);
73         setUseDarkTextColors(true);
74     }
75 
76     /**
77      * Set the primary delegate for the UrlBar view.
78      */
setDelegate(UrlBarDelegate delegate)79     public void setDelegate(UrlBarDelegate delegate) {
80         mModel.set(UrlBarProperties.DELEGATE, delegate);
81     }
82 
83     /** @see UrlBarMediator#setDelegate(UrlBarDelegate) */
addUrlTextChangeListener(UrlTextChangeListener listener)84     public void addUrlTextChangeListener(UrlTextChangeListener listener) {
85         mUrlTextChangeListeners.add(listener);
86     }
87 
88     /** @see android.widget.TextView#addTextChangedListener */
addTextChangedListener(TextWatcher textWatcher)89     public void addTextChangedListener(TextWatcher textWatcher) {
90         mTextChangedListeners.add(textWatcher);
91     }
92 
93     /**
94      * Updates the text content of the UrlBar.
95      *
96      * @param data The new data to be displayed.
97      * @param scrollType The scroll type that should be applied to the data.
98      * @param selectionState Specifies how the text should be selected when focused.
99      * @return Whether this data differs from the previously passed in values.
100      */
setUrlBarData( UrlBarData data, @ScrollType int scrollType, @SelectionState int selectionState)101     public boolean setUrlBarData(
102             UrlBarData data, @ScrollType int scrollType, @SelectionState int selectionState) {
103         if (data.originEndIndex == data.originStartIndex) {
104             scrollType = UrlBar.ScrollType.SCROLL_TO_BEGINNING;
105         }
106 
107         // Do not scroll to the end of the host for URLs such as data:, javascript:, etc...
108         if (data.url != null && data.originEndIndex == data.displayText.length()) {
109             Uri uri = Uri.parse(data.url);
110             String scheme = uri.getScheme();
111             if (!TextUtils.isEmpty(scheme)
112                     && UrlBarData.UNSUPPORTED_SCHEMES_TO_SPLIT.contains(scheme)) {
113                 scrollType = UrlBar.ScrollType.SCROLL_TO_BEGINNING;
114             }
115         }
116 
117         if (!mHasFocus && isNewTextEquivalentToExistingText(mUrlBarData, data)
118                 && mScrollType == scrollType) {
119             return false;
120         }
121         mUrlBarData = data;
122         mScrollType = scrollType;
123         mSelectionState = selectionState;
124 
125         pushTextToModel();
126         return true;
127     }
128 
pushTextToModel()129     private void pushTextToModel() {
130         CharSequence text =
131                 !mHasFocus ? mUrlBarData.displayText : mUrlBarData.getEditingOrDisplayText();
132         CharSequence textForAutofillServices = text;
133 
134         if (!(mHasFocus || TextUtils.isEmpty(text) || mUrlBarData.url == null)) {
135             textForAutofillServices = mUrlBarData.url;
136         }
137 
138         @ScrollType
139         int scrollType = mHasFocus ? UrlBar.ScrollType.NO_SCROLL : mScrollType;
140         if (text == null) text = "";
141 
142         UrlBarTextState state = new UrlBarTextState(text, textForAutofillServices, scrollType,
143                 mUrlBarData.originEndIndex, mSelectionState);
144         mModel.set(UrlBarProperties.TEXT_STATE, state);
145     }
146 
147     @VisibleForTesting
isNewTextEquivalentToExistingText( UrlBarData existingUrlData, UrlBarData newUrlData)148     protected static boolean isNewTextEquivalentToExistingText(
149             UrlBarData existingUrlData, UrlBarData newUrlData) {
150         if (existingUrlData == null) return newUrlData == null;
151         if (newUrlData == null) return false;
152 
153         if (!TextUtils.equals(existingUrlData.editingText, newUrlData.editingText)) return false;
154 
155         CharSequence existingCharSequence = existingUrlData.displayText;
156         CharSequence newCharSequence = newUrlData.displayText;
157         if (existingCharSequence == null) return newCharSequence == null;
158 
159         // Regardless of focus state, ensure the text content is the same.
160         if (!TextUtils.equals(existingCharSequence, newCharSequence)) return false;
161 
162         // If both existing and new text is empty, then treat them equal regardless of their
163         // spanned state.
164         if (TextUtils.isEmpty(newCharSequence)) return true;
165 
166         // When not focused, compare the emphasis spans applied to the text to determine
167         // equality.  Internally, TextView applies many additional spans that need to be
168         // ignored for this comparison to be useful, so this is scoped to only the span types
169         // applied by our UI.
170         if (!(newCharSequence instanceof Spanned) || !(existingCharSequence instanceof Spanned)) {
171             return false;
172         }
173 
174         Spanned currentText = (Spanned) existingCharSequence;
175         Spanned newText = (Spanned) newCharSequence;
176         UrlEmphasisSpan[] currentSpans =
177                 currentText.getSpans(0, currentText.length(), UrlEmphasisSpan.class);
178         UrlEmphasisSpan[] newSpans = newText.getSpans(0, newText.length(), UrlEmphasisSpan.class);
179         if (currentSpans.length != newSpans.length) return false;
180         for (int i = 0; i < currentSpans.length; i++) {
181             UrlEmphasisSpan currentSpan = currentSpans[i];
182             UrlEmphasisSpan newSpan = newSpans[i];
183             if (!currentSpan.equals(newSpan)
184                     || currentText.getSpanStart(currentSpan) != newText.getSpanStart(newSpan)
185                     || currentText.getSpanEnd(currentSpan) != newText.getSpanEnd(newSpan)
186                     || currentText.getSpanFlags(currentSpan) != newText.getSpanFlags(newSpan)) {
187                 return false;
188             }
189         }
190         return true;
191     }
192 
193     /**
194      * Sets the autocomplete text to be shown.
195      *
196      * @param userText The existing user text.
197      * @param autocompleteText The text to be appended to the user text.
198      */
setAutocompleteText(String userText, String autocompleteText)199     public void setAutocompleteText(String userText, String autocompleteText) {
200         if (!mHasFocus) {
201             assert false : "Should not update autocomplete text when not focused";
202             return;
203         }
204         mModel.set(UrlBarProperties.AUTOCOMPLETE_TEXT,
205                 new AutocompleteText(userText, autocompleteText));
206     }
207 
208     /**
209      * Updates the callback that will be notified when the focus changes on the UrlBar.
210      *
211      * @param callback The callback to be notified on focus changes.
212      */
setOnFocusChangedCallback(Callback<Boolean> callback)213     public void setOnFocusChangedCallback(Callback<Boolean> callback) {
214         mOnFocusChangeCallback = callback;
215     }
216 
onUrlFocusChange(boolean focus)217     private void onUrlFocusChange(boolean focus) {
218         mHasFocus = focus;
219 
220         if (mModel.get(UrlBarProperties.ALLOW_FOCUS)) {
221             mModel.set(UrlBarProperties.SHOW_CURSOR, mHasFocus);
222         }
223 
224         UrlBarTextState preCallbackState = mModel.get(UrlBarProperties.TEXT_STATE);
225         if (mOnFocusChangeCallback != null) mOnFocusChangeCallback.onResult(focus);
226         boolean textChangedInFocusCallback =
227                 mModel.get(UrlBarProperties.TEXT_STATE) != preCallbackState;
228         if (mUrlBarData != null && !textChangedInFocusCallback) {
229             pushTextToModel();
230         }
231     }
232 
233     /**
234      * Sets whether to use dark text colors.
235      *
236      * @return Whether this resulted in a change from the previous value.
237      */
setUseDarkTextColors(boolean useDarkColors)238     public boolean setUseDarkTextColors(boolean useDarkColors) {
239         // TODO(bauerb): Make clients observe the property instead of checking the return value.
240         boolean previousValue = mModel.get(UrlBarProperties.USE_DARK_TEXT_COLORS);
241         mModel.set(UrlBarProperties.USE_DARK_TEXT_COLORS, useDarkColors);
242         return previousValue != useDarkColors;
243     }
244 
245     /**
246      * Sets whether the view allows user focus.
247      */
setAllowFocus(boolean allowFocus)248     public void setAllowFocus(boolean allowFocus) {
249         mModel.set(UrlBarProperties.ALLOW_FOCUS, allowFocus);
250         if (allowFocus) {
251             mModel.set(UrlBarProperties.SHOW_CURSOR, mHasFocus);
252         }
253     }
254 
255     /**
256      * Set the listener to be notified for URL direction changes.
257      */
setUrlDirectionListener(UrlDirectionListener listener)258     public void setUrlDirectionListener(UrlDirectionListener listener) {
259         mModel.set(UrlBarProperties.URL_DIRECTION_LISTENER, listener);
260     }
261 
262     /**
263      * Set the delegate that provides Window capabilities.
264      */
setWindowDelegate(WindowDelegate windowDelegate)265     public void setWindowDelegate(WindowDelegate windowDelegate) {
266         mModel.set(UrlBarProperties.WINDOW_DELEGATE, windowDelegate);
267     }
268 
269     /**
270      * Set the callback to handle contextual Action Modes.
271      */
setActionModeCallback(ActionMode.Callback callback)272     public void setActionModeCallback(ActionMode.Callback callback) {
273         mModel.set(UrlBarProperties.ACTION_MODE_CALLBACK, callback);
274     }
275 
276     @Override
getReplacementCutCopyText( String currentText, int selectionStart, int selectionEnd)277     public String getReplacementCutCopyText(
278             String currentText, int selectionStart, int selectionEnd) {
279         if (mUrlBarData == null || mUrlBarData.url == null) return null;
280 
281         // Replace the cut/copy text only applies if the user selected from the beginning of the
282         // display text.
283         if (selectionStart != 0) return null;
284 
285         // Trim to just the currently selected text as that is the only text we are replacing.
286         currentText = currentText.substring(selectionStart, selectionEnd);
287 
288         String formattedUrlLocation;
289         String originalUrlLocation;
290         try {
291             // TODO(bauerb): Use |urlBarData.originEndIndex| for this instead?
292             URL javaUrl = new URL(mUrlBarData.url);
293             formattedUrlLocation = getUrlContentsPrePath(
294                     mUrlBarData.getEditingOrDisplayText().toString(), javaUrl.getHost());
295             originalUrlLocation = getUrlContentsPrePath(mUrlBarData.url, javaUrl.getHost());
296         } catch (MalformedURLException mue) {
297             // Just keep the existing selected text for cut/copy if unable to parse the URL.
298             return null;
299         }
300 
301         // If we are copying/cutting the full previously formatted URL, reset the URL
302         // text before initiating the TextViews handling of the context menu.
303         //
304         // Example:
305         //    Original display text: www.example.com
306         //    Original URL:          http://www.example.com
307         //
308         // Editing State:
309         //    www.example.com/blah/foo
310         //    |<--- Selection --->|
311         //
312         // Resulting clipboard text should be:
313         //    http://www.example.com/blah/
314         //
315         // As long as the full original text was selected, it will replace that with the original
316         // URL and keep any further modifications by the user.
317         if (!currentText.startsWith(formattedUrlLocation)
318                 || selectionEnd < formattedUrlLocation.length()) {
319             return null;
320         }
321 
322         return originalUrlLocation + currentText.substring(formattedUrlLocation.length());
323     }
324 
325     @Override
getTextToPaste()326     public String getTextToPaste() {
327         Context context = ContextUtils.getApplicationContext();
328 
329         ClipboardManager clipboard =
330                 (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
331         ClipData clipData = clipboard.getPrimaryClip();
332         if (clipData == null) return null;
333 
334         StringBuilder builder = new StringBuilder();
335         for (int i = 0; i < clipData.getItemCount(); i++) {
336             builder.append(clipData.getItemAt(i).coerceToText(context));
337         }
338 
339         String stringToPaste = sanitizeTextForPaste(builder.toString());
340         recordPasteMetrics(stringToPaste);
341         return stringToPaste;
342     }
343 
344     @VisibleForTesting
sanitizeTextForPaste(String text)345     protected String sanitizeTextForPaste(String text) {
346         return OmniboxViewUtil.sanitizeTextForPaste(text);
347     }
348 
349     /**
350      * Returns the portion of the URL that precedes the path/query section of the URL.
351      *
352      * @param url The url to be used to find the preceding portion.
353      * @param host The host to be located in the URL to determine the location of the path.
354      * @return The URL contents that precede the path (or the passed in URL if the host is
355      *         not found).
356      */
getUrlContentsPrePath(String url, String host)357     private static String getUrlContentsPrePath(String url, String host) {
358         int hostIndex = url.indexOf(host);
359         if (hostIndex == -1) return url;
360 
361         int pathIndex = url.indexOf('/', hostIndex);
362         if (pathIndex <= 0) return url;
363 
364         return url.substring(0, pathIndex);
365     }
366 
367     /** @see UrlTextChangeListener */
368     @Override
onTextChanged(String textWithoutAutocomplete, String textWithAutocomplete)369     public void onTextChanged(String textWithoutAutocomplete, String textWithAutocomplete) {
370         for (int i = 0; i < mUrlTextChangeListeners.size(); i++) {
371             mUrlTextChangeListeners.get(i).onTextChanged(
372                     textWithoutAutocomplete, textWithAutocomplete);
373         }
374     }
375 
376     /** @see TextWatcher */
377     @Override
beforeTextChanged(CharSequence s, int start, int count, int after)378     public void beforeTextChanged(CharSequence s, int start, int count, int after) {
379         for (int i = 0; i < mTextChangedListeners.size(); i++) {
380             mTextChangedListeners.get(i).beforeTextChanged(s, start, count, after);
381         }
382     }
383 
384     /** @see TextWatcher */
385     @Override
onTextChanged(CharSequence s, int start, int before, int count)386     public void onTextChanged(CharSequence s, int start, int before, int count) {
387         for (int i = 0; i < mTextChangedListeners.size(); i++) {
388             mTextChangedListeners.get(i).onTextChanged(s, start, before, count);
389         }
390     }
391 
392     /** @see TextWatcher */
393     @Override
afterTextChanged(Editable editable)394     public void afterTextChanged(Editable editable) {
395         for (int i = 0; i < mTextChangedListeners.size(); i++) {
396             mTextChangedListeners.get(i).afterTextChanged(editable);
397         }
398     }
399 
recordPasteMetrics(String text)400     private void recordPasteMetrics(String text) {
401         boolean isUrl = BrowserStartupController.getInstance().isFullBrowserStarted()
402                 && AutocompleteCoordinator.qualifyPartialURLQuery(text) != null;
403 
404         long age = System.currentTimeMillis() - Clipboard.getInstance().getLastModifiedTimeMs();
405         RecordHistogram.recordCustomTimesHistogram("MobileOmnibox.LongPressPasteAge", age,
406                 MIN_TIME_MILLIS, MAX_TIME_MILLIS, NUM_OF_BUCKETS);
407         if (isUrl) {
408             RecordHistogram.recordCustomTimesHistogram("MobileOmnibox.LongPressPasteAge.URL", age,
409                     MIN_TIME_MILLIS, MAX_TIME_MILLIS, NUM_OF_BUCKETS);
410         } else {
411             RecordHistogram.recordCustomTimesHistogram("MobileOmnibox.LongPressPasteAge.TEXT", age,
412                     MIN_TIME_MILLIS, MAX_TIME_MILLIS, NUM_OF_BUCKETS);
413         }
414     }
415 }
416