From 80aad8d851601d39f73214c198111ca49e25f654 Mon Sep 17 00:00:00 2001 From: Narayan Kamath Date: Wed, 23 Feb 2011 12:01:13 +0000 Subject: Add autocomplete to the browser UrlInputView. Code and tests based on the google search app. A lot less code would be duplicated if we could somehow override AutoCompleteTextView but that is made impossible by it calling a bunch of stuff in its constructor. To do so would require changes to the existing API. I've verified that the unit test passes, but other browser tests appear to fail - even on a clean branch with none of my changes. Also fixes a minor bug in SearchEngines.getSearchableInfo( ). Change-Id: Ic61bc6b8fa27cd210a45dc181ebf15accf503244 --- src/com/android/browser/SuggestionsAdapter.java | 10 +- src/com/android/browser/TitleBarXLarge.java | 19 +- src/com/android/browser/UrlInputView.java | 7 +- .../browser/autocomplete/SuggestedSpan.java | 34 + .../autocomplete/SuggestedTextController.java | 508 +++++++++++++ .../SuggestiveAutoCompleteTextView.java | 828 +++++++++++++++++++++ .../android/browser/search/SearchEngineInfo.java | 6 +- .../autocomplete/SuggestedTextControllerTest.java | 547 ++++++++++++++ 8 files changed, 1936 insertions(+), 23 deletions(-) create mode 100644 src/com/android/browser/autocomplete/SuggestedSpan.java create mode 100644 src/com/android/browser/autocomplete/SuggestedTextController.java create mode 100644 src/com/android/browser/autocomplete/SuggestiveAutoCompleteTextView.java create mode 100644 tests/src/com/android/browser/autocomplete/SuggestedTextControllerTest.java diff --git a/src/com/android/browser/SuggestionsAdapter.java b/src/com/android/browser/SuggestionsAdapter.java index 6e555396..3636bbf0 100644 --- a/src/com/android/browser/SuggestionsAdapter.java +++ b/src/com/android/browser/SuggestionsAdapter.java @@ -392,11 +392,11 @@ public class SuggestionsAdapter extends BaseAdapter implements Filterable, /** * data object to hold suggestion values */ - class SuggestItem { - String title; - String url; - int type; - String extra; + public class SuggestItem { + public String title; + public String url; + public int type; + public String extra; public SuggestItem(String text, String u, int t) { title = text; diff --git a/src/com/android/browser/TitleBarXLarge.java b/src/com/android/browser/TitleBarXLarge.java index 85935a0e..1b33084e 100644 --- a/src/com/android/browser/TitleBarXLarge.java +++ b/src/com/android/browser/TitleBarXLarge.java @@ -16,6 +16,7 @@ package com.android.browser; +import com.android.browser.autocomplete.SuggestedTextController.TextChangeWatcher; import com.android.browser.search.SearchEngine; import android.app.Activity; @@ -23,10 +24,7 @@ import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; -import android.text.Editable; import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; @@ -45,8 +43,7 @@ import java.util.List; * tabbed title bar for xlarge screen browser */ public class TitleBarXLarge extends TitleBarBase - implements OnClickListener, OnFocusChangeListener, - TextWatcher { + implements OnClickListener, OnFocusChangeListener, TextChangeWatcher { private XLargeUi mUi; @@ -136,7 +133,7 @@ public class TitleBarXLarge extends TitleBarBase mUrlInput.setController(mUiController); mUrlInput.setOnFocusChangeListener(this); mUrlInput.setSelectAllOnFocus(true); - mUrlInput.addTextChangedListener(this); + mUrlInput.addQueryTextWatcher(this); setFocusState(false); } @@ -372,7 +369,7 @@ public class TitleBarXLarge extends TitleBarBase // UrlInput text watcher @Override - public void afterTextChanged(Editable s) { + public void onTextChanged(String newText) { if (mUrlInput.hasFocus()) { // check if input field is empty and adjust voice search state updateSearchMode(true); @@ -381,14 +378,6 @@ public class TitleBarXLarge extends TitleBarBase } } - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - // voicesearch @Override diff --git a/src/com/android/browser/UrlInputView.java b/src/com/android/browser/UrlInputView.java index 2ec21111..362e941b 100644 --- a/src/com/android/browser/UrlInputView.java +++ b/src/com/android/browser/UrlInputView.java @@ -18,6 +18,7 @@ package com.android.browser; import com.android.browser.SuggestionsAdapter.CompletionListener; import com.android.browser.SuggestionsAdapter.SuggestItem; +import com.android.browser.autocomplete.SuggestiveAutoCompleteTextView; import com.android.browser.search.SearchEngine; import com.android.browser.search.SearchEngineInfo; import com.android.browser.search.SearchEngines; @@ -32,7 +33,6 @@ import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; -import android.widget.AutoCompleteTextView; import android.widget.TextView; import android.widget.TextView.OnEditorActionListener; @@ -42,7 +42,7 @@ import java.util.List; * url/search input view * handling suggestions */ -public class UrlInputView extends AutoCompleteTextView +public class UrlInputView extends SuggestiveAutoCompleteTextView implements OnEditorActionListener, CompletionListener, OnItemClickListener { @@ -261,4 +261,7 @@ public class UrlInputView extends AutoCompleteTextView return super.onKeyDown(keyCode, evt); } + public SuggestionsAdapter getAdapter() { + return mAdapter; + } } diff --git a/src/com/android/browser/autocomplete/SuggestedSpan.java b/src/com/android/browser/autocomplete/SuggestedSpan.java new file mode 100644 index 00000000..dc04cb2f --- /dev/null +++ b/src/com/android/browser/autocomplete/SuggestedSpan.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.browser.autocomplete; + +import android.os.Parcel; +import android.text.style.ForegroundColorSpan; + +/** + * Class used to mark the portion of text within {@link SuggestiveEditText} that is suggested. + */ +class SuggestedSpan extends ForegroundColorSpan { + + public SuggestedSpan(Parcel src) { + super(src); + } + + public SuggestedSpan(int color) { + super(color); + } + +} diff --git a/src/com/android/browser/autocomplete/SuggestedTextController.java b/src/com/android/browser/autocomplete/SuggestedTextController.java new file mode 100644 index 00000000..e9b74cea --- /dev/null +++ b/src/com/android/browser/autocomplete/SuggestedTextController.java @@ -0,0 +1,508 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.browser.autocomplete; + +import com.google.common.annotations.VisibleForTesting; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.Editable; +import android.text.Selection; +import android.text.SpanWatcher; +import android.text.Spannable; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.view.View; +import android.widget.EditText; + +import java.util.ArrayList; + +import junit.framework.Assert; + + +/** + * The query editor can show a suggestion, grayed out following the query that the user has + * entered so far. As the user types new characters, these should replace the grayed suggestion + * text. This class manages this logic, displaying the suggestion when the user entered text is a + * prefix of it, and hiding it otherwise. + * + * Note, the text in the text view will contain the entire suggestion, not just what the user + * entered. Instead of retrieving the text from the text view, {@link #getUserText()} should be + * called on this class. + */ +public class SuggestedTextController { + private static final boolean DBG = false; + private static final String TAG = "Browser.SuggestedTextController"; + + private final BufferTextWatcher mBufferTextWatcher = new BufferTextWatcher(); + private final BufferSpanWatcher mBufferSpanWatcher = new BufferSpanWatcher(); + private final ArrayList mTextWatchers; + private final TextOwner mTextOwner; + private final StringBuffer mUserEntered; + private final SuggestedSpan mSuggested; + private String mSuggestedText; + private TextChangeAttributes mCurrentTextChange; + /** + * While this is non-null, any changes made to the cursor position or selection are ignored. Is + * stored the selection state at the moment when selection change processing was disabled. + */ + private BufferSelection mTextSelectionBeforeIgnoringChanges; + + public SuggestedTextController(final EditText textView, int color) { + this(new TextOwner() { + @Override + public Editable getText() { + return textView.getText(); + } + @Override + public void addTextChangedListener(TextWatcher watcher) { + textView.addTextChangedListener(watcher); + } + @Override + public void removeTextChangedListener(TextWatcher watcher) { + textView.removeTextChangedListener(watcher); + } + @Override + public void setText(String text) { + textView.setText(text); + } + }, color); + } + + private void initialize(String userText, int selStart, int selEnd, String suggested) { + Editable text = mTextOwner.getText(); + + if (userText == null) userText = ""; + String allText = userText; + int suggestedStart = allText.length(); + if (suggested != null && userText != null) { + if (suggested.startsWith(userText.toLowerCase())) { + allText = suggested; + } + } + + // allText is at this point either "userText" (not null) or + // "suggested" if thats not null and starts with userText. + text.replace(0, text.length(), allText); + Selection.setSelection(text, selStart, selEnd); + mUserEntered.replace(0, mUserEntered.length(), userText); + mSuggestedText = suggested; + if (suggestedStart < text.length()) { + text.setSpan(mSuggested, suggestedStart, text.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } else { + text.removeSpan(mSuggested); + } + text.setSpan(mBufferSpanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + mTextOwner.addTextChangedListener(mBufferTextWatcher); + if (DBG) checkInvariant(text); + } + + private void assertNotIgnoringSelectionChanges() { + if (mTextSelectionBeforeIgnoringChanges != null) { + throw new IllegalStateException( + "Illegal operation while cursor movement processing suspended"); + } + } + + public Parcelable saveInstanceState(Parcelable superState) { + assertNotIgnoringSelectionChanges(); + SavedState ss = new SavedState(superState); + Editable buffer = mTextOwner.getText(); + ss.mUserText = getUserText(); + ss.mSuggestedText = mSuggestedText; + ss.mSelStart = Selection.getSelectionStart(buffer); + ss.mSelEnd = Selection.getSelectionEnd(buffer); + return ss; + } + + public Parcelable restoreInstanceState(Parcelable state) { + assertNotIgnoringSelectionChanges(); + if (!(state instanceof SavedState)) return state; + SavedState ss = (SavedState) state; + if (DBG) { + Log.d(TAG, "restoreInstanceState t='" + ss.mUserText + "' suggestion='" + + ss.mSuggestedText + " sel=" + ss.mSelStart + ".." + ss.mSelEnd); + } + // remove our listeners so we don't get notifications while re-initialising + mTextOwner.getText().removeSpan(mBufferSpanWatcher); + mTextOwner.removeTextChangedListener(mBufferTextWatcher); + // and initialise will re-add the watchers + initialize(ss.mUserText, ss.mSelStart, ss.mSelEnd, ss.mSuggestedText); + notifyUserEnteredChanged(); + return ss.getSuperState(); + } + + /** + * Temporarily stop processing cursor movements and selection changes. While cursor movements + * are being ignored, the text in the buffer must NOT be changed; doing so will result in an + * {@link IllegalStateException} being thrown. + * + * To stop ignoring cursor movements, call + * {@link #resumeCursorMovementHandlingAndApplyChanges()}. + */ + public void suspendCursorMovementHandling() { + assertNotIgnoringSelectionChanges(); + Editable buffer = mTextOwner.getText(); + mTextSelectionBeforeIgnoringChanges = new BufferSelection(buffer); + } + + /** + * Start responding to cursor movements and selection changes again. If the cursor or selection + * moved while it was being ignored, these changes will be processed now. + */ + public void resumeCursorMovementHandlingAndApplyChanges() { + Editable buffer = mTextOwner.getText(); + BufferSelection oldSelection = mTextSelectionBeforeIgnoringChanges; + mTextSelectionBeforeIgnoringChanges = null; + BufferSelection newSelection = new BufferSelection(buffer); + if (oldSelection.mStart != newSelection.mStart) { + mBufferSpanWatcher.onSpanChanged(buffer, Selection.SELECTION_START, + oldSelection.mStart, oldSelection.mStart, + newSelection.mStart, newSelection.mStart); + } + if (oldSelection.mEnd != newSelection.mEnd) { + mBufferSpanWatcher.onSpanChanged(buffer, Selection.SELECTION_END, + oldSelection.mEnd, oldSelection.mEnd, + newSelection.mEnd, newSelection.mEnd); + } + } + + /** + * Sets the current suggested text. A portion of this will be added to the user entered text if + * that is a prefix of the suggestion. + */ + public void setSuggestedText(String text) { + assertNotIgnoringSelectionChanges(); + if (!TextUtils.equals(text, mSuggestedText)) { + if (DBG) Log.d(TAG, "setSuggestedText(" + text + ")"); + mSuggestedText = text; + if (mCurrentTextChange == null) { + mCurrentTextChange = new TextChangeAttributes(0, 0, 0); + Editable buffer = mTextOwner.getText(); + handleTextChanged(buffer); + } + } + } + + /** + * Gets the portion of displayed text that is not suggested. + */ + public String getUserText() { + assertNotIgnoringSelectionChanges(); + return mUserEntered.toString(); + } + + /** + * Sets the given text as if it has been entered by the user. + */ + public void setText(String text) { + assertNotIgnoringSelectionChanges(); + if (text == null) text = ""; + Editable buffer = mTextOwner.getText(); + buffer.removeSpan(mSuggested); + // this will cause a handleTextChanged call + buffer.replace(0, text.length(), text); + } + + public void addUserTextChangeWatcher(TextChangeWatcher watcher) { + mTextWatchers.add(watcher); + } + + private void handleTextChanged(Editable newText) { + // When we make changes to the buffer from within this function, it results in recursive + // calls to beforeTextChanges(), afterTextChanged(). We want to ignore the changes we're + // making ourself: + if (mCurrentTextChange.isHandled()) return; + mCurrentTextChange.setHandled(); + final int pos = mCurrentTextChange.mPos; + final int countBefore = mCurrentTextChange.mCountBefore; + final int countAfter = mCurrentTextChange.mCountAfter; + final int cursorPos = Selection.getSelectionEnd(newText); + if (DBG) { + Log.d(TAG, "pos=" + pos +"; countBefore=" + countBefore + "; countAfter=" + + countAfter + "; cursor=" + cursorPos); + } + mUserEntered.replace(pos, pos + countBefore, + newText.subSequence(pos, pos + countAfter).toString()); + if (DBG) Log.d(TAG, "User entered: '" + mUserEntered + "' all='" + newText + "'"); + final int userLen = mUserEntered.length(); + boolean haveSuggested = newText.getSpanStart(mSuggested) != -1; + if (mSuggestedText != null && + mSuggestedText.startsWith(mUserEntered.toString().toLowerCase())) { + if (haveSuggested) { + if (!mSuggestedText.equalsIgnoreCase(newText.toString())) { + if (countAfter > countBefore) { + // net insertion + int len = countAfter - countBefore; + newText.delete(pos + len, pos + len + len); + } else { + // net deletion + newText.replace(userLen, newText.length(), + mSuggestedText.substring(userLen)); + if (countBefore == 0) { + // no change to the text - likely just suggested change + Selection.setSelection(newText, cursorPos); + } + } + } + } else { + // no current suggested text - add it + newText.insert(userLen, mSuggestedText.substring(userLen)); + // keep the cursor at the end of the user entered text, if that where it was + // before. + if (cursorPos == userLen) { + Selection.setSelection(newText, userLen); + } + } + if (userLen == newText.length()) { + newText.removeSpan(mSuggested); + } else { + newText.setSpan(mSuggested, userLen, newText.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } else { + if (newText.getSpanStart(mSuggested) != -1) { + newText.removeSpan(mSuggested); + newText.delete(mUserEntered.length(), newText.length()); + } + } + if (DBG) checkInvariant(newText); + mCurrentTextChange = null; + if (countBefore > 0 || countAfter > 0) { + notifyUserEnteredChanged(); + } + } + + private void notifyUserEnteredChanged() { + for (TextChangeWatcher watcher : mTextWatchers) { + watcher.onTextChanged(mUserEntered.toString()); + } + } + + /** + * Basic interface for being notified of changes to some text. + */ + public interface TextChangeWatcher { + void onTextChanged(String newText); + } + + /** + * Interface class to wrap required methods from {@link EditText}, or some other class used + * to test without needing an @{link EditText}. + */ + public interface TextOwner { + Editable getText(); + void addTextChangedListener(TextWatcher watcher); + void removeTextChangedListener(TextWatcher watcher); + void setText(String text); + } + + /** + * This class stores the parameters passed to {@link BufferTextWatcher#beforeTextChanged}, + * together with a flag indicating if this invocation has been dealt with yet. We need this + * information, together with the parameters passed to + * {@link BufferTextWatcher#afterTextChanged}, to restore our internal state when the buffer is + * edited. + * + * Since the changes we make from within {@link BufferTextWatcher#afterTextChanged} also trigger + * further recursive calls to {@link BufferTextWatcher#beforeTextChanged} and + * {@link BufferTextWatcher#afterTextChanged}, this class helps detect these recursive calls so + * they can be ignored. + */ + private static class TextChangeAttributes { + public final int mPos; + public final int mCountAfter; + public final int mCountBefore; + private boolean mHandled; + + public TextChangeAttributes(int pos, int countAfter, int countBefore) { + mPos = pos; + mCountAfter = countAfter; + mCountBefore = countBefore; + } + + public void setHandled() { + mHandled = true; + } + + public boolean isHandled() { + return mHandled; + } + } + + /** + * Encapsulates the state of the text selection (and cursor) within a text buffer. + */ + private static class BufferSelection { + final int mStart; + final int mEnd; + public BufferSelection(CharSequence text) { + mStart = Selection.getSelectionStart(text); + mEnd = Selection.getSelectionEnd(text); + } + @Override + public boolean equals(Object other) { + if (!(other instanceof BufferSelection)) return super.equals(other); + BufferSelection otherSel = (BufferSelection) other; + return this.mStart == otherSel.mStart && this.mEnd == otherSel.mEnd; + } + } + + private class BufferTextWatcher implements TextWatcher { + @Override + public void afterTextChanged(Editable newText) { + if (DBG) { + Log.d(TAG, "afterTextChanged('" + newText + "')"); + } + assertNotIgnoringSelectionChanges(); + handleTextChanged(newText); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + assertNotIgnoringSelectionChanges(); + if (mCurrentTextChange == null) { + mCurrentTextChange = new TextChangeAttributes(start, after, count); + } + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + } + + private class BufferSpanWatcher implements SpanWatcher { + @Override + public void onSpanAdded(Spannable text, Object what, int start, int end) { + } + + @Override + public void onSpanChanged( + Spannable text, Object what, int ostart, int oend, int nstart, int nend) { + if (mCurrentTextChange != null) return; + if (mTextSelectionBeforeIgnoringChanges != null) return; + if (what == Selection.SELECTION_END) { + if (DBG) Log.d(TAG, "cursor move to " + nend); + if (nend > mUserEntered.length()) { + mUserEntered.replace(0, mUserEntered.length(), text.toString()); + text.removeSpan(mSuggested); + } + if (DBG) checkInvariant(text); + } + } + + @Override + public void onSpanRemoved(Spannable text, Object what, int start, int end) { + } + } + + public static class SavedState extends View.BaseSavedState { + String mUserText; + String mSuggestedText; + int mSelStart; + int mSelEnd; + + public SavedState(Parcelable superState) { + super(superState); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeString(mUserText); + out.writeString(mSuggestedText); + out.writeInt(mSelStart); + out.writeInt(mSelEnd); + } + + @SuppressWarnings("hiding") + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + + private SavedState(Parcel in) { + super(in); + mUserText = in.readString(); + mSuggestedText = in.readString(); + mSelStart = in.readInt(); + mSelEnd = in.readInt(); + } + } + + /* + * The remaining functions are used for testing purposes only. + * ----------------------------------------------------------- + */ + + /** + * Verify that the internal state of this class is consistent. + */ + @VisibleForTesting + void checkInvariant(final Spannable s) { + int suggestedStart = s.getSpanStart(mSuggested); + int suggestedEnd = s.getSpanEnd(mSuggested); + int cursorPos = Selection.getSelectionEnd(s); + if (suggestedStart == -1 || suggestedEnd == -1) { + suggestedStart = suggestedEnd = s.length(); + } + String userEntered = getUserText(); + Log.d(TAG, "checkInvariant all='" + s + "' (len " + s.length() + ") sug=" + + suggestedStart + ".." + suggestedEnd + " cursor=" + cursorPos + + " ue='" + userEntered + "' (len " + userEntered.length() + ")"); + int suggestedLen = suggestedEnd - suggestedStart; + Assert.assertEquals("Sum of user and suggested text lengths doesn't match total length", + s.length(), userEntered.length() + suggestedLen); + Assert.assertEquals("End of user entered text doesn't match start of suggested", + suggestedStart, userEntered.length()); + Assert.assertTrue("user entered text does not match start of buffer", + userEntered.toString().equalsIgnoreCase( + s.subSequence(0, suggestedStart).toString())); + if (mSuggestedText != null && suggestedStart < s.length()) { + Assert.assertTrue("User entered is not a prefix of suggested", + mSuggestedText.startsWith(userEntered.toString().toLowerCase())); + Assert.assertTrue("Suggested text does not match buffer contents", + mSuggestedText.equalsIgnoreCase(s.toString().toLowerCase())); + } + if (mSuggestedText == null) { + Assert.assertEquals("Non-zero suggention length with null suggestion", 0, suggestedLen); + } else { + Assert.assertTrue("Suggestion text longer than suggestion (" + mSuggestedText.length() + + ">" + suggestedLen + ")", suggestedLen <= mSuggestedText.length()); + } + Assert.assertTrue("Cursor within suggested part", cursorPos <= suggestedStart); + } + + @VisibleForTesting + SuggestedTextController(TextOwner textOwner, int color) { + mUserEntered = new StringBuffer(); + mSuggested = new SuggestedSpan(color); + mTextOwner = textOwner; + mTextWatchers = new ArrayList(); + initialize(null, 0, 0, null); + } +} diff --git a/src/com/android/browser/autocomplete/SuggestiveAutoCompleteTextView.java b/src/com/android/browser/autocomplete/SuggestiveAutoCompleteTextView.java new file mode 100644 index 00000000..d93066c1 --- /dev/null +++ b/src/com/android/browser/autocomplete/SuggestiveAutoCompleteTextView.java @@ -0,0 +1,828 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.browser.autocomplete; + +import com.android.browser.SuggestionsAdapter; +import com.android.browser.SuggestionsAdapter.SuggestItem; +import com.android.browser.autocomplete.SuggestedTextController.TextChangeWatcher; +import com.android.internal.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.database.DataSetObserver; +import android.graphics.Rect; +import android.os.Parcelable; +import android.text.Editable; +import android.text.Selection; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.Log; +import android.view.AbsSavedState; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.inputmethod.CompletionInfo; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.Filter; +import android.widget.Filterable; +import android.widget.ListAdapter; +import android.widget.ListPopupWindow; +import android.widget.TextView; + + +/** + * This is a stripped down version of the framework AutoCompleteTextView + * class with added support for displaying completions in-place. Note that + * this cannot be implemented as a subclass of the above without making + * substantial changes to it and its descendants. + * + * @see android.widget.AutoCompleteTextView + */ +public class SuggestiveAutoCompleteTextView extends EditText implements Filter.FilterListener { + private static final boolean DEBUG = false; + private static final String TAG = "SuggestiveAutoCompleteTextView"; + + private CharSequence mHintText; + private TextView mHintView; + private int mHintResource; + + private SuggestionsAdapter mAdapter; + private Filter mFilter; + private int mThreshold; + + private ListPopupWindow mPopup; + private int mDropDownAnchorId; + + private AdapterView.OnItemClickListener mItemClickListener; + + private boolean mDropDownDismissedOnCompletion = true; + + private int mLastKeyCode = KeyEvent.KEYCODE_UNKNOWN; + private boolean mOpenBefore; + + // Set to true when text is set directly and no filtering shall be performed + private boolean mBlockCompletion; + + // When set, an update in the underlying adapter will update the result list popup. + // Set to false when the list is hidden to prevent asynchronous updates to popup the list again. + private boolean mPopupCanBeUpdated = true; + + private PassThroughClickListener mPassThroughClickListener; + private PopupDataSetObserver mObserver; + private SuggestedTextController mController; + + public SuggestiveAutoCompleteTextView(Context context) { + this(context, null); + } + + public SuggestiveAutoCompleteTextView(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.autoCompleteTextViewStyle); + } + + public SuggestiveAutoCompleteTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + mController = new SuggestedTextController(this, getHintTextColors().getDefaultColor()); + mPopup = new ListPopupWindow(context, attrs, + R.attr.autoCompleteTextViewStyle); + mPopup.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + mPopup.setPromptPosition(ListPopupWindow.POSITION_PROMPT_BELOW); + + TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.AutoCompleteTextView, defStyle, 0); + + mThreshold = a.getInt( + R.styleable.AutoCompleteTextView_completionThreshold, 2); + + mPopup.setListSelector(a.getDrawable(R.styleable.AutoCompleteTextView_dropDownSelector)); + mPopup.setVerticalOffset((int) + a.getDimension(R.styleable.AutoCompleteTextView_dropDownVerticalOffset, 0.0f)); + mPopup.setHorizontalOffset((int) + a.getDimension(R.styleable.AutoCompleteTextView_dropDownHorizontalOffset, 0.0f)); + + // Get the anchor's id now, but the view won't be ready, so wait to actually get the + // view and store it in mDropDownAnchorView lazily in getDropDownAnchorView later. + // Defaults to NO_ID, in which case the getDropDownAnchorView method will simply return + // this TextView, as a default anchoring point. + mDropDownAnchorId = a.getResourceId( + R.styleable.AutoCompleteTextView_dropDownAnchor, View.NO_ID); + + // For dropdown width, the developer can specify a specific width, or MATCH_PARENT + // (for full screen width) or WRAP_CONTENT (to match the width of the anchored view). + mPopup.setWidth(a.getLayoutDimension( + R.styleable.AutoCompleteTextView_dropDownWidth, + ViewGroup.LayoutParams.WRAP_CONTENT)); + mPopup.setHeight(a.getLayoutDimension( + R.styleable.AutoCompleteTextView_dropDownHeight, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + mHintResource = a.getResourceId(R.styleable.AutoCompleteTextView_completionHintView, + R.layout.simple_dropdown_hint); + + mPopup.setOnItemClickListener(new DropDownItemClickListener()); + setCompletionHint(a.getText(R.styleable.AutoCompleteTextView_completionHint)); + + // Always turn on the auto complete input type flag, since it + // makes no sense to use this widget without it. + int inputType = getInputType(); + if ((inputType&EditorInfo.TYPE_MASK_CLASS) + == EditorInfo.TYPE_CLASS_TEXT) { + inputType |= EditorInfo.TYPE_TEXT_FLAG_AUTO_COMPLETE; + setRawInputType(inputType); + } + + a.recycle(); + + setFocusable(true); + + mController.addUserTextChangeWatcher(new MyWatcher()); + + mPassThroughClickListener = new PassThroughClickListener(); + super.setOnClickListener(mPassThroughClickListener); + } + + @Override + public void setOnClickListener(OnClickListener listener) { + mPassThroughClickListener.mWrapped = listener; + } + + /** + * Private hook into the on click event, dispatched from {@link PassThroughClickListener} + */ + private void onClickImpl() { + // If the dropdown is showing, bring the keyboard to the front + // when the user touches the text field. + if (isPopupShowing()) { + ensureImeVisible(true); + } + } + + /** + *

Sets the optional hint text that is displayed at the bottom of the + * the matching list. This can be used as a cue to the user on how to + * best use the list, or to provide extra information.

+ * + * @param hint the text to be displayed to the user + * + * @attr ref android.R.styleable#AutoCompleteTextView_completionHint + */ + private void setCompletionHint(CharSequence hint) { + mHintText = hint; + if (hint != null) { + if (mHintView == null) { + final TextView hintView = (TextView) LayoutInflater.from(getContext()).inflate( + mHintResource, null).findViewById(R.id.text1); + hintView.setText(mHintText); + mHintView = hintView; + mPopup.setPromptView(hintView); + } else { + mHintView.setText(hint); + } + } else { + mPopup.setPromptView(null); + mHintView = null; + } + } + + protected int getDropDownWidth() { + return mPopup.getWidth(); + } + + public void setDropDownWidth(int width) { + mPopup.setWidth(width); + } + + protected void setDropDownVerticalOffset(int offset) { + mPopup.setVerticalOffset(offset); + } + + public void setDropDownHorizontalOffset(int offset) { + mPopup.setHorizontalOffset(offset); + } + + protected int getDropDownHorizontalOffset() { + return mPopup.getHorizontalOffset(); + } + + protected void setThreshold(int threshold) { + if (threshold <= 0) { + threshold = 1; + } + + mThreshold = threshold; + } + + protected void setOnItemClickListener(AdapterView.OnItemClickListener l) { + mItemClickListener = l; + } + + public void setAdapter(SuggestionsAdapter adapter) { + if (mObserver == null) { + mObserver = new PopupDataSetObserver(); + } else if (mAdapter != null) { + mAdapter.unregisterDataSetObserver(mObserver); + } + mAdapter = adapter; + if (mAdapter != null) { + mFilter = mAdapter.getFilter(); + adapter.registerDataSetObserver(mObserver); + } else { + mFilter = null; + } + + mPopup.setAdapter(mAdapter); + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK && isPopupShowing() + && !mPopup.isDropDownAlwaysVisible()) { + // special case for the back key, we do not even try to send it + // to the drop down list but instead, consume it immediately + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { + KeyEvent.DispatcherState state = getKeyDispatcherState(); + if (state != null) { + state.startTracking(event, this); + } + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + KeyEvent.DispatcherState state = getKeyDispatcherState(); + if (state != null) { + state.handleUpEvent(event); + } + if (event.isTracking() && !event.isCanceled()) { + dismissDropDown(); + return true; + } + } + } + return super.onKeyPreIme(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + boolean consumed = mPopup.onKeyUp(keyCode, event); + if (consumed) { + switch (keyCode) { + // if the list accepts the key events and the key event + // was a click, the text view gets the selected item + // from the drop down as its content + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_TAB: + if (event.hasNoModifiers()) { + performCompletion(); + } + return true; + } + } + + if (isPopupShowing() && keyCode == KeyEvent.KEYCODE_TAB && event.hasNoModifiers()) { + performCompletion(); + return true; + } + + return super.onKeyUp(keyCode, event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (mPopup.onKeyDown(keyCode, event)) { + return true; + } + + if (!isPopupShowing()) { + switch(keyCode) { + case KeyEvent.KEYCODE_DPAD_DOWN: + if (event.hasNoModifiers()) { + performValidation(); + } + } + } + + if (isPopupShowing() && keyCode == KeyEvent.KEYCODE_TAB && event.hasNoModifiers()) { + return true; + } + + mLastKeyCode = keyCode; + boolean handled = super.onKeyDown(keyCode, event); + mLastKeyCode = KeyEvent.KEYCODE_UNKNOWN; + + if (handled && isPopupShowing()) { + clearListSelection(); + } + + return handled; + } + + /** + * Returns true if the amount of text in the field meets + * or exceeds the {@link #getThreshold} requirement. You can override + * this to impose a different standard for when filtering will be + * triggered. + */ + private boolean enoughToFilter() { + if (DEBUG) Log.v(TAG, "Enough to filter: len=" + getText().length() + + " threshold=" + mThreshold); + return getText().length() >= mThreshold; + } + + /** + * This is used to watch for edits to the text view. Note that we call + * to methods on the auto complete text view class so that we can access + * private vars without going through thunks. + */ + private class MyWatcher implements TextWatcher, TextChangeWatcher { + @Override + public void afterTextChanged(Editable s) { + doAfterTextChanged(); + } + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + doBeforeTextChanged(); + } + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + @Override + public void onTextChanged(String newText) { + doAfterTextChanged(); + } + } + + /** + * @hide + */ + protected void setBlockCompletion(boolean block) { + mBlockCompletion = block; + } + + void doBeforeTextChanged() { + if (mBlockCompletion) return; + + // when text is changed, inserted or deleted, we attempt to show + // the drop down + mOpenBefore = isPopupShowing(); + if (DEBUG) Log.v(TAG, "before text changed: open=" + mOpenBefore); + } + + void doAfterTextChanged() { + if (DEBUG) Log.d(TAG, "doAfterTextChanged(" + getText() + ")"); + if (mBlockCompletion) return; + + // if the list was open before the keystroke, but closed afterwards, + // then something in the keystroke processing (an input filter perhaps) + // called performCompletion() and we shouldn't do any more processing. + if (DEBUG) Log.v(TAG, "after text changed: openBefore=" + mOpenBefore + + " open=" + isPopupShowing()); + if (mOpenBefore && !isPopupShowing()) { + return; + } + + // the drop down is shown only when a minimum number of characters + // was typed in the text view + if (enoughToFilter()) { + if (mFilter != null) { + mPopupCanBeUpdated = true; + performFiltering(mController.getUserText(), mLastKeyCode); + buildImeCompletions(); + } + } else { + // drop down is automatically dismissed when enough characters + // are deleted from the text view + if (!mPopup.isDropDownAlwaysVisible()) { + dismissDropDown(); + } + if (mFilter != null) { + mFilter.filter(null); + } + } + } + + /** + *

Indicates whether the popup menu is showing.

+ * + * @return true if the popup menu is showing, false otherwise + */ + protected boolean isPopupShowing() { + return mPopup.isShowing(); + } + + /** + *

Converts the selected item from the drop down list into a sequence + * of character that can be used in the edit box.

+ * + * @param selectedItem the item selected by the user for completion + * + * @return a sequence of characters representing the selected suggestion + */ + protected CharSequence convertSelectionToString(Object selectedItem) { + return mFilter.convertResultToString(selectedItem); + } + + /** + *

Clear the list selection. This may only be temporary, as user input will often bring + * it back. + */ + private void clearListSelection() { + mPopup.clearListSelection(); + } + + /** + *

Starts filtering the content of the drop down list. The filtering + * pattern is the content of the edit box. Subclasses should override this + * method to filter with a different pattern, for instance a substring of + * text.

+ * + * @param text the filtering pattern + * @param keyCode the last character inserted in the edit box; beware that + * this will be null when text is being added through a soft input method. + */ + @SuppressWarnings({ "UnusedDeclaration" }) + protected void performFiltering(CharSequence text, int keyCode) { + if (DEBUG) Log.d(TAG, "performFiltering(" + text + ")"); + + mFilter.filter(text, this); + } + + /** + *

Performs the text completion by converting the selected item from + * the drop down list into a string, replacing the text box's content with + * this string and finally dismissing the drop down menu.

+ */ + private void performCompletion() { + performCompletion(null, -1, -1); + } + + @Override + public void onCommitCompletion(CompletionInfo completion) { + if (isPopupShowing()) { + mBlockCompletion = true; + replaceText(completion.getText()); + mBlockCompletion = false; + + mPopup.performItemClick(completion.getPosition()); + } + } + + private void performCompletion(View selectedView, int position, long id) { + if (isPopupShowing()) { + Object selectedItem; + if (position < 0) { + selectedItem = mPopup.getSelectedItem(); + } else { + selectedItem = mAdapter.getItem(position); + } + if (selectedItem == null) { + Log.w(TAG, "performCompletion: no selected item"); + return; + } + + mBlockCompletion = true; + replaceText(convertSelectionToString(selectedItem)); + mBlockCompletion = false; + + if (mItemClickListener != null) { + final ListPopupWindow list = mPopup; + + if (selectedView == null || position < 0) { + selectedView = list.getSelectedView(); + position = list.getSelectedItemPosition(); + id = list.getSelectedItemId(); + } + mItemClickListener.onItemClick(list.getListView(), selectedView, position, id); + } + } + + if (mDropDownDismissedOnCompletion && !mPopup.isDropDownAlwaysVisible()) { + dismissDropDown(); + } + } + + /** + *

Performs the text completion by replacing the current text by the + * selected item. Subclasses should override this method to avoid replacing + * the whole content of the edit box.

+ * + * @param text the selected suggestion in the drop down list + */ + protected void replaceText(CharSequence text) { + clearComposingText(); + + setText(text); + // make sure we keep the caret at the end of the text view + Editable spannable = getText(); + Selection.setSelection(spannable, spannable.length()); + } + + /** {@inheritDoc} */ + @Override + public void onFilterComplete(int count) { + updateDropDownForFilter(count); + } + + private void updateDropDownForFilter(int count) { + // Not attached to window, don't update drop-down + if (getWindowVisibility() == View.GONE) return; + + /* + * This checks enoughToFilter() again because filtering requests + * are asynchronous, so the result may come back after enough text + * has since been deleted to make it no longer appropriate + * to filter. + */ + + final boolean dropDownAlwaysVisible = mPopup.isDropDownAlwaysVisible(); + if ((count > 0 || dropDownAlwaysVisible) && enoughToFilter() && + mController.getUserText().length() > 0) { + if (hasFocus() && hasWindowFocus() && mPopupCanBeUpdated) { + showDropDown(); + } + } else if (!dropDownAlwaysVisible && isPopupShowing()) { + dismissDropDown(); + // When the filter text is changed, the first update from the adapter may show an empty + // count (when the query is being performed on the network). Future updates when some + // content has been retrieved should still be able to update the list. + mPopupCanBeUpdated = true; + } + } + + @Override + public void onWindowFocusChanged(boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + if (!hasWindowFocus && !mPopup.isDropDownAlwaysVisible()) { + dismissDropDown(); + } + } + + @Override + protected void onDisplayHint(int hint) { + super.onDisplayHint(hint); + switch (hint) { + case INVISIBLE: + if (!mPopup.isDropDownAlwaysVisible()) { + dismissDropDown(); + } + break; + } + } + + @Override + protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { + // TextView makes several cursor movements when gaining focus, and this interferes with + // the suggested vs user entered text. Tell the controller to temporarily ignore cursor + // movements while this is going on. + mController.suspendCursorMovementHandling(); + + super.onFocusChanged(focused, direction, previouslyFocusedRect); + // Perform validation if the view is losing focus. + if (!focused) { + performValidation(); + } + if (!focused && !mPopup.isDropDownAlwaysVisible()) { + dismissDropDown(); + } + + mController.resumeCursorMovementHandlingAndApplyChanges(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + } + + @Override + protected void onDetachedFromWindow() { + dismissDropDown(); + super.onDetachedFromWindow(); + } + + /** + *

Closes the drop down if present on screen.

+ */ + protected void dismissDropDown() { + InputMethodManager imm = InputMethodManager.peekInstance(); + if (imm != null) { + imm.displayCompletions(this, null); + } + mPopup.dismiss(); + mPopupCanBeUpdated = false; + } + + @Override + protected boolean setFrame(final int l, int t, final int r, int b) { + boolean result = super.setFrame(l, t, r, b); + + if (isPopupShowing()) { + showDropDown(); + } + + return result; + } + + /** + * Ensures that the drop down is not obscuring the IME. + * @param visible whether the ime should be in front. If false, the ime is pushed to + * the background. + * @hide internal used only here and SearchDialog + */ + private void ensureImeVisible(boolean visible) { + mPopup.setInputMethodMode(visible + ? ListPopupWindow.INPUT_METHOD_NEEDED : ListPopupWindow.INPUT_METHOD_NOT_NEEDED); + showDropDown(); + } + + /** + *

Displays the drop down on screen.

+ */ + protected void showDropDown() { + if (mPopup.getAnchorView() == null) { + if (mDropDownAnchorId != View.NO_ID) { + mPopup.setAnchorView(getRootView().findViewById(mDropDownAnchorId)); + } else { + mPopup.setAnchorView(this); + } + } + if (!isPopupShowing()) { + // Make sure the list does not obscure the IME when shown for the first time. + mPopup.setInputMethodMode(ListPopupWindow.INPUT_METHOD_NEEDED); + } + mPopup.show(); + } + + private void buildImeCompletions() { + final ListAdapter adapter = mAdapter; + if (adapter != null) { + InputMethodManager imm = InputMethodManager.peekInstance(); + if (imm != null) { + final int count = Math.min(adapter.getCount(), 20); + CompletionInfo[] completions = new CompletionInfo[count]; + int realCount = 0; + + for (int i = 0; i < count; i++) { + if (adapter.isEnabled(i)) { + realCount++; + Object item = adapter.getItem(i); + long id = adapter.getItemId(i); + completions[i] = new CompletionInfo(id, i, + convertSelectionToString(item)); + } + } + + if (realCount != count) { + CompletionInfo[] tmp = new CompletionInfo[realCount]; + System.arraycopy(completions, 0, tmp, 0, realCount); + completions = tmp; + } + + imm.displayCompletions(this, completions); + } + } + } + + private void performValidation() { + } + + /** + * Returns the Filter obtained from {@link Filterable#getFilter}, + * or null if {@link #setAdapter} was not called with + * a Filterable. + */ + protected Filter getFilter() { + return mFilter; + } + + private class DropDownItemClickListener implements AdapterView.OnItemClickListener { + @Override + public void onItemClick(AdapterView parent, View v, int position, long id) { + performCompletion(v, position, id); + } + } + + /** + * Allows us a private hook into the on click event without preventing users from setting + * their own click listener. + */ + private class PassThroughClickListener implements OnClickListener { + + private View.OnClickListener mWrapped; + + /** {@inheritDoc} */ + @Override + public void onClick(View v) { + onClickImpl(); + + if (mWrapped != null) mWrapped.onClick(v); + } + } + + private class PopupDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + if (mAdapter != null) { + // If the popup is not showing already, showing it will cause + // the list of data set observers attached to the adapter to + // change. We can't do it from here, because we are in the middle + // of iterating through the list of observers. + post(new Runnable() { + @Override + public void run() { + final SuggestionsAdapter adapter = mAdapter; + if (adapter != null) { + // This will re-layout, thus resetting mDataChanged, so that the + // listView click listener stays responsive + updateDropDownForFilter(adapter.getCount()); + } + + updateText(adapter); + } + }); + } + } + } + + private void updateText(SuggestionsAdapter adapter) { + // FIXME: Turn this on only when instant is being used. + // if (!BrowserSettings.getInstance().useInstant()) { + // return; + // } + + if (!isPopupShowing()) { + setSuggestedText(null); + return; + } + + if (mAdapter.getCount() > 0 && !TextUtils.isEmpty(mController.getUserText())) { + SuggestItem item = adapter.getItem(0); + setSuggestedText(item.title); + } + } + + @Override + public void setText(CharSequence text, BufferType type) { + Editable buffer = getEditableText(); + if (text == null) text = ""; + // if we already have a buffer, we must not replace it with a new one as this will break + // mController. Write the new text into the existing buffer instead. + if (buffer == null) { + super.setText(text, type); + } else { + buffer.replace(0, buffer.length(), text); + } + } + + public void setText(CharSequence text, boolean filter) { + if (filter) { + setText(text); + } else { + mBlockCompletion = true; + setText(text); + mBlockCompletion = false; + } + } + + @Override + public Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + if (superState instanceof TextView.SavedState) { + // get rid of TextView's saved state, we override it. + superState = ((TextView.SavedState) superState).getSuperState(); + } + if (superState == null) { + superState = AbsSavedState.EMPTY_STATE; + } + return mController.saveInstanceState(superState); + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + super.onRestoreInstanceState(mController.restoreInstanceState(state)); + } + + public void addQueryTextWatcher(final SuggestedTextController.TextChangeWatcher watcher) { + mController.addUserTextChangeWatcher(watcher); + } + + public void setSuggestedText(String text) { + mController.setSuggestedText(text); + } +} diff --git a/src/com/android/browser/search/SearchEngineInfo.java b/src/com/android/browser/search/SearchEngineInfo.java index 6f0b1d5b..af6fa706 100644 --- a/src/com/android/browser/search/SearchEngineInfo.java +++ b/src/com/android/browser/search/SearchEngineInfo.java @@ -17,6 +17,7 @@ package com.android.browser.search; import android.content.Context; import android.content.res.Resources; +import android.content.res.Resources.NotFoundException; import android.text.TextUtils; import android.util.Log; @@ -58,9 +59,12 @@ public class SearchEngineInfo { */ public SearchEngineInfo(Context context, String name) throws IllegalArgumentException { mName = name; - Resources res = context.getResources(); + int id_data = res.getIdentifier(name, "array", context.getPackageName()); + if (id_data == 0) { + throw new IllegalArgumentException("No resources found for " + name); + } mSearchEngineData = res.getStringArray(id_data); if (mSearchEngineData == null) { diff --git a/tests/src/com/android/browser/autocomplete/SuggestedTextControllerTest.java b/tests/src/com/android/browser/autocomplete/SuggestedTextControllerTest.java new file mode 100644 index 00000000..f162e3b0 --- /dev/null +++ b/tests/src/com/android/browser/autocomplete/SuggestedTextControllerTest.java @@ -0,0 +1,547 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.browser.autocomplete; + +import com.android.browser.autocomplete.SuggestedTextController.TextOwner; + +import android.graphics.Color; +import android.os.Parcelable; +import android.test.AndroidTestCase; +import android.test.suitebuilder.annotation.SmallTest; +import android.text.Editable; +import android.text.Selection; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.TextWatcher; +import android.view.AbsSavedState; + +/** + * Test cases for {@link SuggestedTextController}. + */ +@SmallTest +public class SuggestedTextControllerTest extends AndroidTestCase { + + // these two must have a common prefix (but not be identical): + private static final String RUBY_MURRAY = "ruby murray"; + private static final String RUBY_TUESDAY = "ruby tuesday"; + private static final String EXTRA_USER_TEXT = " curry"; + // no common prefix with the top two above: + private static final String TOD_SLOAN = "tod sloan"; + + private SuggestedTextController mController; + private SpannableStringBuilder mString; + + private SuggestedTextController m2ndController; + private SpannableStringBuilder m2ndString; + + @Override + public void setUp() throws Exception { + super.setUp(); + mString = new SpannableStringBuilder(); + Selection.setSelection(mString, 0); // position cursor + mController = new SuggestedTextController(new BufferTextOwner(mString), Color.GRAY); + checkInvariant(); + } + + private void create2ndController() { + m2ndString = new SpannableStringBuilder(); + Selection.setSelection(m2ndString, 0); // position cursor + m2ndController = new SuggestedTextController(new BufferTextOwner(m2ndString), Color.GRAY); + check2ndInvariant(); + } + + private int cursorPos(Spannable string) { + int selStart = Selection.getSelectionStart(string); + int selEnd = Selection.getSelectionEnd(string); + assertEquals("Selection has non-zero length", selStart, selEnd); + return selEnd; + } + + private int cursorPos() { + return cursorPos(mString); + } + + private void insertAtCursor(String text) { + mString.insert(cursorPos(), text); + checkInvariant(); + } + + private void insertAtCursor(char ch) { + insertAtCursor(Character.toString(ch)); + } + + private void insertAt2ndCursor(String text) { + m2ndString.insert(cursorPos(m2ndString), text); + check2ndInvariant(); + } + + private void insertAt2ndCursor(char ch) { + insertAt2ndCursor(Character.toString(ch)); + } + + private void deleteBeforeCursor(int count) { + int pos = cursorPos(); + count = Math.min(pos, count); + mString.delete(pos - count, pos); + checkInvariant(); + } + + private void replaceSelection(String withThis) { + mString.replace(Selection.getSelectionStart(mString), + Selection.getSelectionEnd(mString), withThis); + checkInvariant(); + } + + private void setSuggested(String suggested) { + mController.setSuggestedText(suggested); + checkInvariant(); + } + + private void set2ndSuggested(String suggested) { + m2ndController.setSuggestedText(suggested); + check2ndInvariant(); + } + + private void checkInvariant() { + mController.checkInvariant(mString); + } + + private void check2ndInvariant() { + m2ndController.checkInvariant(m2ndString); + } + + private void assertUserEntered(String expected, SuggestedTextController controller) { + assertEquals("User entered text not as expected", expected, controller.getUserText()); + } + + private void assertUserEntered(String expected) { + assertUserEntered(expected, mController); + } + + private void assertBuffer(String expected, Editable string) { + assertEquals("Buffer contents not as expected", expected, string.toString()); + } + + private void assertBuffer(String expected) { + assertBuffer(expected, mString); + } + + private void assertCursorPos(int where, Spannable string) { + assertEquals("Cursor not at expected position", where, cursorPos(string)); + } + + private void assertCursorPos(int where) { + assertCursorPos(where, mString); + } + + private static final String commonPrefix(String a, String b) { + int pos = 0; + while (a.charAt(pos) == b.charAt(pos)) { + pos++; + } + assertTrue("No common prefix between '" + a + "' and '" + b + "'", pos > 0); + return a.substring(0, pos); + } + + public void testTypeNoSuggested() { + for (int i = 0; i < RUBY_MURRAY.length(); ++i) { + assertCursorPos(i); + assertUserEntered(RUBY_MURRAY.substring(0, i)); + assertBuffer(RUBY_MURRAY.substring(0, i)); + insertAtCursor(RUBY_MURRAY.substring(i, i + 1)); + } + } + + public void testTypeSuggested() { + setSuggested(RUBY_MURRAY); + assertCursorPos(0); + assertBuffer(RUBY_MURRAY); + for (int i = 0; i < RUBY_MURRAY.length(); ++i) { + assertCursorPos(i); + assertUserEntered(RUBY_MURRAY.substring(0, i)); + assertBuffer(RUBY_MURRAY); + insertAtCursor(RUBY_MURRAY.charAt(i)); + } + } + + public void testSetSuggestedAfterTextEntry() { + final int count = RUBY_MURRAY.length() / 2; + for (int i = 0; i < count; ++i) { + assertCursorPos(i); + assertUserEntered(RUBY_MURRAY.substring(0, i)); + insertAtCursor(RUBY_MURRAY.substring(i, i + 1)); + } + setSuggested(RUBY_MURRAY); + assertUserEntered(RUBY_MURRAY.substring(0, count)); + assertBuffer(RUBY_MURRAY); + } + + public void testTypeSuggestedUpperCase() { + setSuggested(RUBY_MURRAY); + assertCursorPos(0); + for (int i = 0; i < RUBY_MURRAY.length(); ++i) { + assertCursorPos(i); + assertUserEntered(RUBY_MURRAY.substring(0, i).toUpperCase()); + assertTrue("Buffer doesn't contain suggested text", + RUBY_MURRAY.equalsIgnoreCase(mString.toString())); + insertAtCursor(Character.toUpperCase(RUBY_MURRAY.charAt(i))); + } + } + + public void testChangeSuggestedText() { + String pref = commonPrefix(RUBY_MURRAY, RUBY_TUESDAY); + setSuggested(RUBY_MURRAY); + insertAtCursor(pref); + assertBuffer(RUBY_MURRAY); + assertUserEntered(pref); + setSuggested(RUBY_TUESDAY); + assertBuffer(RUBY_TUESDAY); + assertUserEntered(pref); + } + + public void testTypeNonSuggested() { + setSuggested(RUBY_MURRAY); + insertAtCursor(RUBY_MURRAY.charAt(0)); + assertBuffer(RUBY_MURRAY); + insertAtCursor('x'); + assertBuffer("rx"); + } + + public void testTypeNonSuggestedThenNewSuggestion() { + final String pref = commonPrefix(RUBY_MURRAY, RUBY_TUESDAY); + setSuggested(RUBY_MURRAY); + assertCursorPos(0); + insertAtCursor(pref); + assertCursorPos(pref.length()); + assertUserEntered(pref); + insertAtCursor(RUBY_TUESDAY.charAt(pref.length())); + assertBuffer(RUBY_TUESDAY.substring(0, pref.length() + 1)); + setSuggested(RUBY_TUESDAY); + assertBuffer(RUBY_TUESDAY); + } + + public void testChangeSuggestedToNonUserEntered() { + final String half = RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2); + setSuggested(RUBY_MURRAY); + insertAtCursor(half); + setSuggested(TOD_SLOAN); + assertUserEntered(half); + assertBuffer(half); + } + + public void testChangeSuggestedToUserEntered() { + setSuggested(RUBY_MURRAY); + insertAtCursor(TOD_SLOAN); + setSuggested(TOD_SLOAN); + assertUserEntered(TOD_SLOAN); + assertBuffer(TOD_SLOAN); + } + + public void testChangeSuggestedToEmpty() { + final String half = RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2); + setSuggested(RUBY_MURRAY); + insertAtCursor(half); + setSuggested(null); + assertUserEntered(half); + assertBuffer(half); + } + + public void testChangeSuggestedToEmptyFromUserEntered() { + setSuggested(RUBY_MURRAY); + insertAtCursor(RUBY_MURRAY); + setSuggested(null); + assertUserEntered(RUBY_MURRAY); + assertBuffer(RUBY_MURRAY); + } + + public void typeNonSuggestedThenDelete() { + final String half = RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2); + assertCursorPos(0); + insertAtCursor(half); + assertCursorPos(half.length()); + setSuggested(RUBY_MURRAY); + insertAtCursor('x'); + assertBuffer(half + "x"); + deleteBeforeCursor(1); + assertUserEntered(half); + assertBuffer(RUBY_MURRAY); + } + + public void testDeleteMultipleFromSuggested() { + final String twoThirds = RUBY_MURRAY.substring(0, (RUBY_MURRAY.length() * 2) / 3); + setSuggested(RUBY_MURRAY); + insertAtCursor(twoThirds); + assertCursorPos(twoThirds.length()); + // select some of the text just entered: + Selection.setSelection(mString, RUBY_MURRAY.length() / 3, twoThirds.length()); + // and delete it: + replaceSelection(""); + assertCursorPos(RUBY_MURRAY.length() / 3); + assertUserEntered(RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 3)); + assertBuffer(RUBY_MURRAY); + } + + public void testDeleteMultipleToFormSuggested() { + final String pref = commonPrefix(RUBY_TUESDAY, RUBY_MURRAY); + final int extra = (RUBY_TUESDAY.length() - pref.length()) / 2; + setSuggested(RUBY_MURRAY); + insertAtCursor(RUBY_TUESDAY.substring(0, pref.length() + extra)); + assertCursorPos(pref.length() + extra); + // select and delete extra characters, leaving just prefix + Selection.setSelection(mString, pref.length(), pref.length() + extra); + replaceSelection(""); + assertCursorPos(pref.length()); + assertBuffer(RUBY_MURRAY); + assertUserEntered(pref); + } + + public void testBackspaceWithinUserTextFromSuggested() { + StringBuffer half = new StringBuffer(RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2)); + insertAtCursor(half.toString()); + int backSpaceFrom = half.length() / 2; + Selection.setSelection(mString, backSpaceFrom); + deleteBeforeCursor(1); + assertCursorPos(backSpaceFrom - 1); + half.delete(backSpaceFrom - 1, backSpaceFrom); + assertUserEntered(half.toString()); + assertBuffer(half.toString()); + } + + public void testInsertWithinUserTextToFormSuggested() { + final String half = RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2); + StringBuffer initial = new StringBuffer(half); + int pos = initial.length() / 2; + char toInsert = initial.charAt(pos); + initial.delete(pos, pos + 1); + insertAtCursor(initial.toString()); + setSuggested(RUBY_MURRAY); + assertUserEntered(initial.toString()); + assertBuffer(initial.toString()); + Selection.setSelection(mString, pos); + insertAtCursor(toInsert); + assertCursorPos(pos + 1); + assertUserEntered(half); + assertBuffer(RUBY_MURRAY); + } + + public void testEnterTextBeyondSuggested() { + setSuggested(RUBY_MURRAY); + int i = RUBY_MURRAY.length() / 2; + insertAtCursor(RUBY_MURRAY.substring(0, i)); + String query = RUBY_MURRAY + EXTRA_USER_TEXT; + for (; i < query.length(); ++i) { + assertUserEntered(query.substring(0, i)); + if (i <= RUBY_MURRAY.length()) { + assertBuffer(RUBY_MURRAY); + } + insertAtCursor(query.charAt(i)); + } + assertUserEntered(query); + } + + public void testDeleteFromLongerThanSuggested() { + setSuggested(RUBY_MURRAY); + final String entered = RUBY_MURRAY + EXTRA_USER_TEXT; + insertAtCursor(entered); + for (int i = entered.length(); i > (RUBY_MURRAY.length() / 2); --i) { + assertCursorPos(i); + assertUserEntered(entered.substring(0, i)); + if (i <= RUBY_MURRAY.length()) { + assertBuffer(RUBY_MURRAY); + } + deleteBeforeCursor(1); + } + } + + public void testReplaceWithShorterToFormSuggested() { + final String pref = commonPrefix(RUBY_TUESDAY, RUBY_MURRAY); + final int extra = (RUBY_TUESDAY.length() - pref.length()) / 2; + setSuggested(RUBY_MURRAY); + insertAtCursor(RUBY_TUESDAY.substring(0, pref.length() + extra)); + assertCursorPos(pref.length() + extra); + // select and replace extra characters, to match suggested + Selection.setSelection(mString, pref.length(), pref.length() + extra); + replaceSelection(RUBY_MURRAY.substring(pref.length(), pref.length() + extra - 1)); + assertBuffer(RUBY_MURRAY); + assertUserEntered(RUBY_MURRAY.substring(0, pref.length() + extra - 1)); + } + + public void testReplaceWithSameLengthToFormSuggested() { + final String pref = commonPrefix(RUBY_TUESDAY, RUBY_MURRAY); + final int extra = (RUBY_TUESDAY.length() - pref.length()) / 2; + setSuggested(RUBY_MURRAY); + insertAtCursor(RUBY_TUESDAY.substring(0, pref.length() + extra)); + assertCursorPos(pref.length() + extra); + // select and replace extra characters, to match suggested + Selection.setSelection(mString, pref.length(), pref.length() + extra); + replaceSelection(RUBY_MURRAY.substring(pref.length(), pref.length() + extra)); + assertBuffer(RUBY_MURRAY); + assertUserEntered(RUBY_MURRAY.substring(0, pref.length() + extra)); + } + + public void testReplaceWithLongerToFormSuggested() { + final String pref = commonPrefix(RUBY_TUESDAY, RUBY_MURRAY); + final int extra = (RUBY_TUESDAY.length() - pref.length()) / 2; + setSuggested(RUBY_MURRAY); + insertAtCursor(RUBY_TUESDAY.substring(0, pref.length() + extra)); + assertCursorPos(pref.length() + extra); + // select and replace extra characters, to match suggested + Selection.setSelection(mString, pref.length(), pref.length() + extra); + replaceSelection(RUBY_MURRAY.substring(pref.length(), pref.length() + extra + 1)); + assertBuffer(RUBY_MURRAY); + assertUserEntered(RUBY_MURRAY.substring(0, pref.length() + extra + 1)); + } + + public void testMoveCursorIntoSuggested() { + final String half = RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2); + insertAtCursor(half); + setSuggested(RUBY_MURRAY); + assertCursorPos(half.length()); + Selection.setSelection(mString, half.length() + 1); + checkInvariant(); + assertUserEntered(RUBY_MURRAY); + } + + public void testMoveCursorWithinUserEntered() { + final String half = RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2); + insertAtCursor(half); + setSuggested(RUBY_MURRAY); + assertCursorPos(half.length()); + Selection.setSelection(mString, half.length() - 1); + checkInvariant(); + assertUserEntered(half); + } + + public void testSelectWithinSuggested() { + final String half = RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2); + insertAtCursor(half); + setSuggested(RUBY_MURRAY); + assertCursorPos(half.length()); + Selection.setSelection(mString, half.length() + 1, half.length() + 2); + checkInvariant(); + assertUserEntered(RUBY_MURRAY); + } + + public void testSelectStraddlingSuggested() { + final String half = RUBY_MURRAY.substring(0, RUBY_MURRAY.length() / 2); + insertAtCursor(half); + setSuggested(RUBY_MURRAY); + assertCursorPos(half.length()); + Selection.setSelection(mString, half.length() - 1, half.length() + 1); + checkInvariant(); + assertUserEntered(RUBY_MURRAY); + } + + public void testSaveAndRestoreNoText() { + create2ndController(); + Parcelable state = mController.saveInstanceState(AbsSavedState.EMPTY_STATE); + m2ndController.restoreInstanceState(state); + check2ndInvariant(); + assertBuffer("", m2ndString); + } + + public void testSaveAndRestoreWithSuggestedText() { + create2ndController(); + setSuggested(TOD_SLOAN); + Parcelable state = mController.saveInstanceState(AbsSavedState.EMPTY_STATE); + m2ndController.restoreInstanceState(state); + check2ndInvariant(); + assertBuffer(TOD_SLOAN, m2ndString); + assertUserEntered("", m2ndController); + } + + public void testSaveAndRestoreWithUserEnteredAndSuggestedText() { + final String half = TOD_SLOAN.substring(0, TOD_SLOAN.length() / 2); + create2ndController(); + setSuggested(TOD_SLOAN); + insertAtCursor(half); + Parcelable state = mController.saveInstanceState(AbsSavedState.EMPTY_STATE); + m2ndController.restoreInstanceState(state); + check2ndInvariant(); + assertBuffer(TOD_SLOAN, m2ndString); + assertUserEntered(half, m2ndController); + assertCursorPos(half.length(), m2ndString); + } + + public void testSaveAndRestoreWithNonSuggested() { + final String half = TOD_SLOAN.substring(0, TOD_SLOAN.length() / 2); + create2ndController(); + setSuggested(RUBY_MURRAY); + insertAtCursor(half); + Parcelable state = mController.saveInstanceState(AbsSavedState.EMPTY_STATE); + m2ndController.restoreInstanceState(state); + check2ndInvariant(); + assertBuffer(half, m2ndString); + assertUserEntered(half, m2ndController); + assertCursorPos(half.length(), m2ndString); + } + + public void testSaveAndRestoreThenTypeSuggested() { + final String half = TOD_SLOAN.substring(0, TOD_SLOAN.length() / 2); + create2ndController(); + set2ndSuggested(TOD_SLOAN); + insertAt2ndCursor(half); + insertAt2ndCursor('x'); + Parcelable state = m2ndController.saveInstanceState(AbsSavedState.EMPTY_STATE); + mController.restoreInstanceState(state); + assertCursorPos(half.length() + 1); + // delete the x + deleteBeforeCursor(1); + assertCursorPos(half.length()); + assertBuffer(TOD_SLOAN); + assertUserEntered(half); + } + + public void testSuspendAndResumeCursorProcessing() { + final String half = TOD_SLOAN.substring(0, TOD_SLOAN.length() / 2); + setSuggested(TOD_SLOAN); + insertAtCursor(half); + mController.suspendCursorMovementHandling(); + Selection.setSelection(mString, TOD_SLOAN.length()); + Selection.setSelection(mString, half.length()); + mController.resumeCursorMovementHandlingAndApplyChanges(); + assertCursorPos(half.length()); + assertUserEntered(half); + assertBuffer(TOD_SLOAN); + } + + private static class BufferTextOwner implements TextOwner { + + private final Editable mBuffer; + + public BufferTextOwner(Editable buffer) { + mBuffer = buffer; + } + + public void addTextChangedListener(TextWatcher watcher) { + mBuffer.setSpan(watcher , 0, mBuffer.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + + public void removeTextChangedListener(TextWatcher watcher) { + mBuffer.removeSpan(watcher); + } + + public Editable getText() { + return mBuffer; + } + + public void setText(String text) { + mBuffer.replace(0, mBuffer.length(), text); + } + + } + +} -- cgit v1.2.3