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