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