1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- 2 * This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 5 6 package org.mozilla.gecko.toolbar; 7 8 import org.mozilla.gecko.AboutPages; 9 import org.mozilla.gecko.AppConstants.Versions; 10 import org.mozilla.gecko.CustomEditText; 11 import org.mozilla.gecko.InputMethods; 12 import org.mozilla.gecko.R; 13 import org.mozilla.gecko.toolbar.BrowserToolbar.OnCommitListener; 14 import org.mozilla.gecko.toolbar.BrowserToolbar.OnDismissListener; 15 import org.mozilla.gecko.toolbar.BrowserToolbar.OnFilterListener; 16 import org.mozilla.gecko.toolbar.ToolbarEditLayout.OnSearchStateChangeListener; 17 import org.mozilla.gecko.util.GamepadUtils; 18 import org.mozilla.gecko.util.StringUtils; 19 20 import android.content.Context; 21 import android.graphics.Rect; 22 import android.text.Editable; 23 import android.text.NoCopySpan; 24 import android.text.Selection; 25 import android.text.Spanned; 26 import android.text.TextUtils; 27 import android.text.TextWatcher; 28 import android.text.style.BackgroundColorSpan; 29 import android.util.AttributeSet; 30 import android.util.Log; 31 import android.view.KeyEvent; 32 import android.view.View; 33 import android.view.inputmethod.BaseInputConnection; 34 import android.view.inputmethod.EditorInfo; 35 import android.view.inputmethod.InputConnection; 36 import android.view.inputmethod.InputConnectionWrapper; 37 import android.view.inputmethod.InputMethodManager; 38 import android.view.accessibility.AccessibilityEvent; 39 import android.widget.TextView; 40 41 /** 42 * {@code ToolbarEditText} is the text entry used when the toolbar 43 * is in edit state. It handles all the necessary input method machinery. 44 * It's meant to be owned by {@code ToolbarEditLayout}. 45 */ 46 public class ToolbarEditText extends CustomEditText 47 implements AutocompleteHandler { 48 49 private static final String LOGTAG = "GeckoToolbarEditText"; 50 private static final NoCopySpan AUTOCOMPLETE_SPAN = new NoCopySpan.Concrete(); 51 52 private final Context mContext; 53 54 private OnCommitListener mCommitListener; 55 private OnDismissListener mDismissListener; 56 private OnFilterListener mFilterListener; 57 private OnSearchStateChangeListener mSearchStateChangeListener; 58 59 private ToolbarPrefs mPrefs; 60 61 // The previous autocomplete result returned to us 62 private String mAutoCompleteResult = ""; 63 // Length of the user-typed portion of the result 64 private int mAutoCompletePrefixLength; 65 // If text change is due to us setting autocomplete 66 private boolean mSettingAutoComplete; 67 // Spans used for marking the autocomplete text 68 private Object[] mAutoCompleteSpans; 69 // Do not process autocomplete result 70 private boolean mDiscardAutoCompleteResult; 71 ToolbarEditText(Context context, AttributeSet attrs)72 public ToolbarEditText(Context context, AttributeSet attrs) { 73 super(context, attrs); 74 mContext = context; 75 } 76 setOnCommitListener(OnCommitListener listener)77 void setOnCommitListener(OnCommitListener listener) { 78 mCommitListener = listener; 79 } 80 setOnDismissListener(OnDismissListener listener)81 void setOnDismissListener(OnDismissListener listener) { 82 mDismissListener = listener; 83 } 84 setOnFilterListener(OnFilterListener listener)85 void setOnFilterListener(OnFilterListener listener) { 86 mFilterListener = listener; 87 } 88 setOnSearchStateChangeListener(OnSearchStateChangeListener listener)89 void setOnSearchStateChangeListener(OnSearchStateChangeListener listener) { 90 mSearchStateChangeListener = listener; 91 } 92 93 @Override onAttachedToWindow()94 public void onAttachedToWindow() { 95 super.onAttachedToWindow(); 96 setOnKeyListener(new KeyListener()); 97 setOnKeyPreImeListener(new KeyPreImeListener()); 98 setOnSelectionChangedListener(new SelectionChangeListener()); 99 addTextChangedListener(new TextChangeListener()); 100 } 101 102 @Override onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect)103 public void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { 104 super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); 105 106 // Make search icon inactive when edit toolbar search term isn't a user entered 107 // search term 108 final boolean isActive = !TextUtils.isEmpty(getText()); 109 if (mSearchStateChangeListener != null) { 110 mSearchStateChangeListener.onSearchStateChange(isActive); 111 } 112 113 if (gainFocus) { 114 resetAutocompleteState(); 115 return; 116 } 117 118 removeAutocomplete(getText()); 119 120 final InputMethodManager imm = InputMethods.getInputMethodManager(mContext); 121 try { 122 imm.restartInput(this); 123 imm.hideSoftInputFromWindow(getWindowToken(), 0); 124 } catch (NullPointerException e) { 125 Log.e(LOGTAG, "InputMethodManagerService, why are you throwing" 126 + " a NullPointerException? See bug 782096", e); 127 } 128 } 129 130 @Override setText(final CharSequence text, final TextView.BufferType type)131 public void setText(final CharSequence text, final TextView.BufferType type) { 132 final String textString = (text == null) ? "" : text.toString(); 133 134 // If we're on the home or private browsing page, we don't set the "about" url. 135 final CharSequence finalText; 136 if (AboutPages.isAboutHome(textString) || AboutPages.isAboutPrivateBrowsing(textString)) { 137 finalText = ""; 138 } else { 139 finalText = text; 140 } 141 142 super.setText(finalText, type); 143 144 // Any autocomplete text would have been overwritten, so reset our autocomplete states. 145 resetAutocompleteState(); 146 } 147 148 @Override sendAccessibilityEventUnchecked(AccessibilityEvent event)149 public void sendAccessibilityEventUnchecked(AccessibilityEvent event) { 150 // We need to bypass the isShown() check in the default implementation 151 // for TYPE_VIEW_TEXT_SELECTION_CHANGED events so that accessibility 152 // services could detect a url change. 153 if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED && 154 getParent() != null && !isShown()) { 155 onInitializeAccessibilityEvent(event); 156 dispatchPopulateAccessibilityEvent(event); 157 getParent().requestSendAccessibilityEvent(this, event); 158 } else { 159 super.sendAccessibilityEventUnchecked(event); 160 } 161 } 162 setToolbarPrefs(final ToolbarPrefs prefs)163 void setToolbarPrefs(final ToolbarPrefs prefs) { 164 mPrefs = prefs; 165 } 166 167 /** 168 * Mark the start of autocomplete changes so our text change 169 * listener does not react to changes in autocomplete text 170 */ beginSettingAutocomplete()171 private void beginSettingAutocomplete() { 172 beginBatchEdit(); 173 mSettingAutoComplete = true; 174 } 175 176 /** 177 * Mark the end of autocomplete changes 178 */ endSettingAutocomplete()179 private void endSettingAutocomplete() { 180 mSettingAutoComplete = false; 181 endBatchEdit(); 182 } 183 184 /** 185 * Reset autocomplete states to their initial values 186 */ resetAutocompleteState()187 private void resetAutocompleteState() { 188 mAutoCompleteSpans = new Object[] { 189 // Span to mark the autocomplete text 190 AUTOCOMPLETE_SPAN, 191 // Span to change the autocomplete text color 192 new BackgroundColorSpan(getHighlightColor()) 193 }; 194 195 mAutoCompleteResult = ""; 196 197 // Pretend we already autocompleted the existing text, 198 // so that actions like backspacing don't trigger autocompletion. 199 mAutoCompletePrefixLength = getText().length(); 200 201 // Show the cursor. 202 setCursorVisible(true); 203 } 204 getNonAutocompleteText()205 protected String getNonAutocompleteText() { 206 return getNonAutocompleteText(getText()); 207 } 208 209 /** 210 * Get the portion of text that is not marked as autocomplete text. 211 * 212 * @param text Current text content that may include autocomplete text 213 */ getNonAutocompleteText(final Editable text)214 private static String getNonAutocompleteText(final Editable text) { 215 final int start = text.getSpanStart(AUTOCOMPLETE_SPAN); 216 if (start < 0) { 217 // No autocomplete text; return the whole string. 218 return text.toString(); 219 } 220 221 // Only return the portion that's not autocomplete text 222 return TextUtils.substring(text, 0, start); 223 } 224 225 /** 226 * Remove any autocomplete text 227 * 228 * @param text Current text content that may include autocomplete text 229 */ removeAutocomplete(final Editable text)230 private boolean removeAutocomplete(final Editable text) { 231 final int start = text.getSpanStart(AUTOCOMPLETE_SPAN); 232 if (start < 0) { 233 // No autocomplete text 234 return false; 235 } 236 237 beginSettingAutocomplete(); 238 239 // When we call delete() here, the autocomplete spans we set are removed as well. 240 text.delete(start, text.length()); 241 242 // Keep mAutoCompletePrefixLength the same because the prefix has not changed. 243 // Clear mAutoCompleteResult to make sure we get fresh autocomplete text next time. 244 mAutoCompleteResult = ""; 245 246 // Reshow the cursor. 247 setCursorVisible(true); 248 249 endSettingAutocomplete(); 250 return true; 251 } 252 253 /** 254 * Convert any autocomplete text to regular text 255 * 256 * @param text Current text content that may include autocomplete text 257 */ commitAutocomplete(final Editable text)258 private boolean commitAutocomplete(final Editable text) { 259 final int start = text.getSpanStart(AUTOCOMPLETE_SPAN); 260 if (start < 0) { 261 // No autocomplete text 262 return false; 263 } 264 265 beginSettingAutocomplete(); 266 267 // Remove all spans here to convert from autocomplete text to regular text 268 for (final Object span : mAutoCompleteSpans) { 269 text.removeSpan(span); 270 } 271 272 // Keep mAutoCompleteResult the same because the result has not changed. 273 // Reset mAutoCompletePrefixLength because the prefix now includes the autocomplete text. 274 mAutoCompletePrefixLength = text.length(); 275 276 // Reshow the cursor. 277 setCursorVisible(true); 278 279 endSettingAutocomplete(); 280 281 // Filter on the new text 282 if (mFilterListener != null) { 283 mFilterListener.onFilter(text.toString(), null); 284 } 285 return true; 286 } 287 288 /** 289 * Add autocomplete text based on the result URI. 290 * 291 * @param result Result URI to be turned into autocomplete text 292 */ 293 @Override onAutocomplete(final String result)294 public final void onAutocomplete(final String result) { 295 // If mDiscardAutoCompleteResult is true, we temporarily disabled 296 // autocomplete (due to backspacing, etc.) and we should bail early. 297 if (mDiscardAutoCompleteResult) { 298 return; 299 } 300 301 if (!isEnabled() || result == null) { 302 mAutoCompleteResult = ""; 303 return; 304 } 305 306 final Editable text = getText(); 307 final int textLength = text.length(); 308 final int resultLength = result.length(); 309 final int autoCompleteStart = text.getSpanStart(AUTOCOMPLETE_SPAN); 310 mAutoCompleteResult = result; 311 312 if (autoCompleteStart > -1) { 313 // Autocomplete text already exists; we should replace existing autocomplete text. 314 315 // If the result and the current text don't have the same prefixes, 316 // the result is stale and we should wait for the another result to come in. 317 if (!TextUtils.regionMatches(result, 0, text, 0, autoCompleteStart)) { 318 return; 319 } 320 321 beginSettingAutocomplete(); 322 323 // Replace the existing autocomplete text with new one. 324 // replace() preserves the autocomplete spans that we set before. 325 text.replace(autoCompleteStart, textLength, result, autoCompleteStart, resultLength); 326 327 // Reshow the cursor if there is no longer any autocomplete text. 328 if (autoCompleteStart == resultLength) { 329 setCursorVisible(true); 330 } 331 332 endSettingAutocomplete(); 333 334 } else { 335 // No autocomplete text yet; we should add autocomplete text 336 337 // If the result prefix doesn't match the current text, 338 // the result is stale and we should wait for the another result to come in. 339 if (resultLength <= textLength || 340 !TextUtils.regionMatches(result, 0, text, 0, textLength)) { 341 return; 342 } 343 344 final Object[] spans = text.getSpans(textLength, textLength, Object.class); 345 final int[] spanStarts = new int[spans.length]; 346 final int[] spanEnds = new int[spans.length]; 347 final int[] spanFlags = new int[spans.length]; 348 349 // Save selection/composing span bounds so we can restore them later. 350 for (int i = 0; i < spans.length; i++) { 351 final Object span = spans[i]; 352 final int spanFlag = text.getSpanFlags(span); 353 354 // We don't care about spans that are not selection or composing spans. 355 // For those spans, spanFlag[i] will be 0 and we don't restore them. 356 if ((spanFlag & Spanned.SPAN_COMPOSING) == 0 && 357 (span != Selection.SELECTION_START) && 358 (span != Selection.SELECTION_END)) { 359 continue; 360 } 361 362 spanStarts[i] = text.getSpanStart(span); 363 spanEnds[i] = text.getSpanEnd(span); 364 spanFlags[i] = spanFlag; 365 } 366 367 beginSettingAutocomplete(); 368 369 // First add trailing text. 370 text.append(result, textLength, resultLength); 371 372 // Restore selection/composing spans. 373 for (int i = 0; i < spans.length; i++) { 374 final int spanFlag = spanFlags[i]; 375 if (spanFlag == 0) { 376 // Skip if the span was ignored before. 377 continue; 378 } 379 text.setSpan(spans[i], spanStarts[i], spanEnds[i], spanFlag); 380 } 381 382 // Mark added text as autocomplete text. 383 for (final Object span : mAutoCompleteSpans) { 384 text.setSpan(span, textLength, resultLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 385 } 386 387 // Hide the cursor. 388 setCursorVisible(false); 389 390 // Make sure the autocomplete text is visible. If the autocomplete text is too 391 // long, it would appear the cursor will be scrolled out of view. However, this 392 // is not the case in practice, because EditText still makes sure the cursor is 393 // still in view. 394 bringPointIntoView(resultLength); 395 396 endSettingAutocomplete(); 397 } 398 } 399 hasCompositionString(Editable content)400 private static boolean hasCompositionString(Editable content) { 401 Object[] spans = content.getSpans(0, content.length(), Object.class); 402 403 if (spans != null) { 404 for (Object span : spans) { 405 if ((content.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { 406 // Found composition string. 407 return true; 408 } 409 } 410 } 411 412 return false; 413 } 414 415 /** 416 * Code to handle deleting autocomplete first when backspacing. 417 * If there is no autocomplete text, both removeAutocomplete() and commitAutocomplete() 418 * are no-ops and return false. Therefore we can use them here without checking explicitly 419 * if we have autocomplete text or not. 420 */ 421 @Override onCreateInputConnection(final EditorInfo outAttrs)422 public InputConnection onCreateInputConnection(final EditorInfo outAttrs) { 423 final InputConnection ic = super.onCreateInputConnection(outAttrs); 424 if (ic == null) { 425 return null; 426 } 427 428 return new InputConnectionWrapper(ic, false) { 429 @Override 430 public boolean deleteSurroundingText(final int beforeLength, final int afterLength) { 431 if (removeAutocomplete(getText())) { 432 // If we have autocomplete text, the cursor is at the boundary between 433 // regular and autocomplete text. So regardless of which direction we 434 // are deleting, we should delete the autocomplete text first. 435 // Make the IME aware that we interrupted the deleteSurroundingText call, 436 // by restarting the IME. 437 final InputMethodManager imm = InputMethods.getInputMethodManager(mContext); 438 if (imm != null) { 439 imm.restartInput(ToolbarEditText.this); 440 } 441 return false; 442 } 443 return super.deleteSurroundingText(beforeLength, afterLength); 444 } 445 446 private boolean removeAutocompleteOnComposing(final CharSequence text) { 447 final Editable editable = getText(); 448 final int composingStart = BaseInputConnection.getComposingSpanStart(editable); 449 final int composingEnd = BaseInputConnection.getComposingSpanEnd(editable); 450 // We only delete the autocomplete text when the user is backspacing, 451 // i.e. when the composing text is getting shorter. 452 if (composingStart >= 0 && 453 composingEnd >= 0 && 454 (composingEnd - composingStart) > text.length() && 455 removeAutocomplete(editable)) { 456 // Make the IME aware that we interrupted the setComposingText call, 457 // by having finishComposingText() send change notifications to the IME. 458 finishComposingText(); 459 setComposingRegion(composingStart, composingEnd); 460 return true; 461 } 462 return false; 463 } 464 465 @Override 466 public boolean commitText(CharSequence text, int newCursorPosition) { 467 if (removeAutocompleteOnComposing(text)) { 468 return false; 469 } 470 return super.commitText(text, newCursorPosition); 471 } 472 473 @Override 474 public boolean setComposingText(final CharSequence text, final int newCursorPosition) { 475 if (removeAutocompleteOnComposing(text)) { 476 return false; 477 } 478 return super.setComposingText(text, newCursorPosition); 479 } 480 }; 481 } 482 483 private class SelectionChangeListener implements OnSelectionChangedListener { 484 @Override 485 public void onSelectionChanged(final int selStart, final int selEnd) { 486 // The user has repositioned the cursor somewhere. We need to adjust 487 // the autocomplete text depending on where the new cursor is. 488 489 final Editable text = getText(); 490 final int start = text.getSpanStart(AUTOCOMPLETE_SPAN); 491 492 if (mSettingAutoComplete || start < 0 || (start == selStart && start == selEnd)) { 493 // Do not commit autocomplete text if there is no autocomplete text 494 // or if selection is still at start of autocomplete text 495 return; 496 } 497 498 if (selStart <= start && selEnd <= start) { 499 // The cursor is in user-typed text; remove any autocomplete text. 500 removeAutocomplete(text); 501 } else { 502 // The cursor is in the autocomplete text; commit it so it becomes regular text. 503 commitAutocomplete(text); 504 } 505 } 506 } 507 508 private class TextChangeListener implements TextWatcher { 509 @Override 510 public void afterTextChanged(final Editable editable) { 511 if (!isEnabled() || mSettingAutoComplete) { 512 return; 513 } 514 515 final String text = getNonAutocompleteText(editable); 516 final int textLength = text.length(); 517 boolean doAutocomplete = mPrefs.shouldAutocomplete(); 518 519 if (StringUtils.isSearchQuery(text, false)) { 520 doAutocomplete = false; 521 } else if (mAutoCompletePrefixLength > textLength) { 522 // If you're hitting backspace (the string is getting smaller), don't autocomplete 523 doAutocomplete = false; 524 } 525 526 mAutoCompletePrefixLength = textLength; 527 528 // If we are not autocompleting, we set mDiscardAutoCompleteResult to true 529 // to discard any autocomplete results that are in-flight, and vice versa. 530 mDiscardAutoCompleteResult = !doAutocomplete; 531 532 if (doAutocomplete && mAutoCompleteResult.startsWith(text)) { 533 // If this text already matches our autocomplete text, autocomplete likely 534 // won't change. Just reuse the old autocomplete value. 535 onAutocomplete(mAutoCompleteResult); 536 doAutocomplete = false; 537 } else { 538 // Otherwise, remove the old autocomplete text 539 // until any new autocomplete text gets added. 540 removeAutocomplete(editable); 541 } 542 543 // Update search icon with an active state since user is typing 544 if (mSearchStateChangeListener != null) { 545 mSearchStateChangeListener.onSearchStateChange(textLength > 0); 546 } 547 548 if (mFilterListener != null) { 549 mFilterListener.onFilter(text, doAutocomplete ? ToolbarEditText.this : null); 550 } 551 } 552 553 @Override 554 public void beforeTextChanged(CharSequence s, int start, int count, 555 int after) { 556 // do nothing 557 } 558 559 @Override 560 public void onTextChanged(CharSequence s, int start, int before, 561 int count) { 562 // do nothing 563 } 564 } 565 566 private class KeyPreImeListener implements OnKeyPreImeListener { 567 @Override 568 public boolean onKeyPreIme(View v, int keyCode, KeyEvent event) { 569 // We only want to process one event per tap 570 if (event.getAction() != KeyEvent.ACTION_DOWN) { 571 return false; 572 } 573 574 if (keyCode == KeyEvent.KEYCODE_ENTER) { 575 // If the edit text has a composition string, don't submit the text yet. 576 // ENTER is needed to commit the composition string. 577 final Editable content = getText(); 578 if (!hasCompositionString(content)) { 579 if (mCommitListener != null) { 580 mCommitListener.onCommit(); 581 } 582 583 return true; 584 } 585 } 586 587 if (keyCode == KeyEvent.KEYCODE_BACK) { 588 // Drop the virtual keyboard. 589 clearFocus(); 590 return true; 591 } 592 593 return false; 594 } 595 } 596 597 private class KeyListener implements View.OnKeyListener { 598 @Override 599 public boolean onKey(View v, int keyCode, KeyEvent event) { 600 if (keyCode == KeyEvent.KEYCODE_ENTER || GamepadUtils.isActionKey(event)) { 601 if (event.getAction() != KeyEvent.ACTION_DOWN) { 602 return true; 603 } 604 605 if (mCommitListener != null) { 606 mCommitListener.onCommit(); 607 } 608 609 return true; 610 } 611 612 if (GamepadUtils.isBackKey(event)) { 613 if (mDismissListener != null) { 614 mDismissListener.onDismiss(); 615 } 616 617 return true; 618 } 619 620 if ((keyCode == KeyEvent.KEYCODE_DEL || 621 (keyCode == KeyEvent.KEYCODE_FORWARD_DEL)) && 622 removeAutocomplete(getText())) { 623 // Delete autocomplete text when backspacing or forward deleting. 624 return true; 625 } 626 627 return false; 628 } 629 } 630 } 631