From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- .../org/mozilla/search/AcceptsSearchQuery.java | 48 +++ .../search/java/org/mozilla/search/Constants.java | 20 + .../org/mozilla/search/PostSearchFragment.java | 243 ++++++++++++ .../java/org/mozilla/search/PreSearchFragment.java | 218 +++++++++++ .../java/org/mozilla/search/SearchActivity.java | 436 +++++++++++++++++++++ .../mozilla/search/SearchPreferenceActivity.java | 118 ++++++ .../java/org/mozilla/search/SearchWidget.java | 135 +++++++ .../search/autocomplete/AutoCompleteAdapter.java | 82 ++++ .../org/mozilla/search/autocomplete/SearchBar.java | 201 ++++++++++ .../search/autocomplete/SuggestionsFragment.java | 263 +++++++++++++ .../org/mozilla/search/ui/BackCaptureEditText.java | 36 ++ .../java/org/mozilla/search/ui/FacetBar.java | 124 ++++++ 12 files changed, 1924 insertions(+) create mode 100644 mobile/android/search/java/org/mozilla/search/AcceptsSearchQuery.java create mode 100644 mobile/android/search/java/org/mozilla/search/Constants.java create mode 100644 mobile/android/search/java/org/mozilla/search/PostSearchFragment.java create mode 100644 mobile/android/search/java/org/mozilla/search/PreSearchFragment.java create mode 100644 mobile/android/search/java/org/mozilla/search/SearchActivity.java create mode 100644 mobile/android/search/java/org/mozilla/search/SearchPreferenceActivity.java create mode 100644 mobile/android/search/java/org/mozilla/search/SearchWidget.java create mode 100644 mobile/android/search/java/org/mozilla/search/autocomplete/AutoCompleteAdapter.java create mode 100644 mobile/android/search/java/org/mozilla/search/autocomplete/SearchBar.java create mode 100644 mobile/android/search/java/org/mozilla/search/autocomplete/SuggestionsFragment.java create mode 100644 mobile/android/search/java/org/mozilla/search/ui/BackCaptureEditText.java create mode 100644 mobile/android/search/java/org/mozilla/search/ui/FacetBar.java (limited to 'mobile/android/search/java/org') diff --git a/mobile/android/search/java/org/mozilla/search/AcceptsSearchQuery.java b/mobile/android/search/java/org/mozilla/search/AcceptsSearchQuery.java new file mode 100644 index 000000000..e54b9a9fc --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/AcceptsSearchQuery.java @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search; + +import android.graphics.Rect; + +/** + * Allows fragments to pass a search event to the main activity. + */ +public interface AcceptsSearchQuery { + + /** + * Shows search suggestions. + * @param query + */ + void onSuggest(String query); + + /** + * Starts a search. + * + * @param query + */ + void onSearch(String query); + + /** + * Starts a search and animates a suggestion. + * + * @param query + * @param suggestionAnimation + */ + void onSearch(String query, SuggestionAnimation suggestionAnimation); + + /** + * Handles a change to the current search query. + * + * @param query + */ + void onQueryChange(String query); + + /** + * Interface to specify search suggestion animation details. + */ + public interface SuggestionAnimation { + public Rect getStartBounds(); + } +} diff --git a/mobile/android/search/java/org/mozilla/search/Constants.java b/mobile/android/search/java/org/mozilla/search/Constants.java new file mode 100644 index 000000000..8e8a17600 --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/Constants.java @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search; + +/** + * Key should not be stored here. For more info on storing keys, see + * https://github.com/ericedens/FirefoxSearch/issues/3 + */ +public class Constants { + + public static final String ABOUT_BLANK = "about:blank"; +} diff --git a/mobile/android/search/java/org/mozilla/search/PostSearchFragment.java b/mobile/android/search/java/org/mozilla/search/PostSearchFragment.java new file mode 100644 index 000000000..8a26c49dd --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/PostSearchFragment.java @@ -0,0 +1,243 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.R; +import org.mozilla.gecko.search.SearchEngine; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.provider.Settings; +import android.support.v4.app.Fragment; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +public class PostSearchFragment extends Fragment { + + private static final String LOG_TAG = "PostSearchFragment"; + + private SearchEngine engine; + + private ProgressBar progressBar; + private WebView webview; + private View errorView; + + private String resultsPageHost; + + @SuppressLint("SetJavaScriptEnabled") + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View mainView = inflater.inflate(R.layout.search_fragment_post_search, container, false); + + progressBar = (ProgressBar) mainView.findViewById(R.id.progress_bar); + + webview = (WebView) mainView.findViewById(R.id.webview); + webview.setWebChromeClient(new ChromeClient()); + webview.setWebViewClient(new ResultsWebViewClient()); + + // This is required for our greasemonkey terror script. + webview.getSettings().setJavaScriptEnabled(true); + + return mainView; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + webview.setWebChromeClient(null); + webview.setWebViewClient(null); + webview = null; + progressBar = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + GeckoApplication.watchReference(getActivity(), this); + } + + public void startSearch(SearchEngine engine, String query) { + this.engine = engine; + + final String url = engine.resultsUriForQuery(query); + // Only load urls if the url is different than the webview's current url. + if (!TextUtils.equals(webview.getUrl(), url)) { + resultsPageHost = null; + webview.loadUrl(Constants.ABOUT_BLANK); + webview.loadUrl(url); + } + } + + /** + * A custom WebViewClient that intercepts every page load. This allows + * us to decide whether to load the url here, or send it to Android + * as an intent. It also handles network errors. + */ + private class ResultsWebViewClient extends WebViewClient { + + // Whether or not there is a network error. + private boolean networkError; + + @Override + public void onPageStarted(WebView view, final String url, Bitmap favicon) { + // Reset the error state. + networkError = false; + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + // Ignore about:blank URL loads and the first results page we try to load. + if (TextUtils.equals(url, Constants.ABOUT_BLANK) || resultsPageHost == null) { + return false; + } + + String host = null; + try { + host = new URL(url).getHost(); + } catch (MalformedURLException e) { + Log.e(LOG_TAG, "Error getting host from URL loading in webview", e); + } + + // If the host name is the same as the results page, don't override the URL load, but + // do update the query in the search bar if possible. + if (TextUtils.equals(resultsPageHost, host)) { + // This won't work for results pages that redirect (e.g. Google in different country) + final String query = engine.queryForResultsUrl(url); + if (!TextUtils.isEmpty(query)) { + ((AcceptsSearchQuery) getActivity()).onQueryChange(query); + } + return false; + } + + try { + // If the url URI does not have an intent scheme, the intent data will be the entire + // URI and its action will be ACTION_VIEW. + final Intent i = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); + + // If the intent URI didn't specify a package, open this in Fennec. + if (i.getPackage() == null) { + i.setClassName(view.getContext().getPackageName(), AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, + TelemetryContract.Method.CONTENT, "search-result"); + } else { + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, + TelemetryContract.Method.INTENT, "search-result"); + } + + i.addCategory(Intent.CATEGORY_BROWSABLE); + i.setComponent(null); + i.setSelector(null); + + startActivity(i); + return true; + } catch (URISyntaxException e) { + Log.e(LOG_TAG, "Error parsing intent URI", e); + } catch (SecurityException e) { + Log.e(LOG_TAG, "SecurityException handling arbitrary intent content"); + } catch (ActivityNotFoundException e) { + Log.e(LOG_TAG, "Intent not actionable"); + } + + return false; + } + + // We are suppressing the 'deprecation' warning because the new method is only available starting with API + // level 23 and that's much higher than our current minSdkLevel (1208580). + @SuppressWarnings("deprecation") + @Override + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + Log.e(LOG_TAG, "Error loading search results: " + description); + + networkError = true; + + if (errorView == null) { + final ViewStub errorViewStub = (ViewStub) getView().findViewById(R.id.error_view_stub); + errorView = errorViewStub.inflate(); + + ((ImageView) errorView.findViewById(R.id.empty_image)).setImageResource(R.drawable.network_error); + ((TextView) errorView.findViewById(R.id.empty_title)).setText(R.string.network_error_title); + + final TextView message = (TextView) errorView.findViewById(R.id.empty_message); + message.setText(R.string.network_error_message); + message.setTextColor(ContextCompat.getColor(view.getContext(), R.color.network_error_link)); + message.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + startActivity(new Intent(Settings.ACTION_SETTINGS)); + } + }); + } + } + + @Override + public void onPageFinished(WebView view, String url) { + // Make sure the error view is hidden if the network error was fixed. + if (errorView != null) { + errorView.setVisibility(networkError ? View.VISIBLE : View.GONE); + webview.setVisibility(networkError ? View.GONE : View.VISIBLE); + } + + if (!TextUtils.equals(url, Constants.ABOUT_BLANK) && resultsPageHost == null) { + try { + resultsPageHost = new URL(url).getHost(); + } catch (MalformedURLException e) { + Log.e(LOG_TAG, "Error getting host from results page URL", e); + } + } + } + } + + /** + * A custom WebChromeClient that allows us to inject CSS into + * the head of the HTML and to monitor pageload progress. + * + * We use the WebChromeClient because it provides a hook to the titleReceived + * event. Once the title is available, the page will have started parsing the + * head element. The script injects its CSS into the head element. + */ + private class ChromeClient extends WebChromeClient { + + @Override + public void onReceivedTitle(final WebView view, String title) { + view.loadUrl(engine.getInjectableJs()); + } + + @Override + public void onProgressChanged(WebView view, int newProgress) { + if (newProgress < 100) { + if (progressBar.getVisibility() == View.INVISIBLE) { + progressBar.setVisibility(View.VISIBLE); + } + progressBar.setProgress(newProgress); + } else { + progressBar.setVisibility(View.INVISIBLE); + } + } + } +} diff --git a/mobile/android/search/java/org/mozilla/search/PreSearchFragment.java b/mobile/android/search/java/org/mozilla/search/PreSearchFragment.java new file mode 100644 index 000000000..107b82c5c --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/PreSearchFragment.java @@ -0,0 +1,218 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v4.widget.SimpleCursorAdapter; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.widget.AdapterView; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.SearchHistory; +import org.mozilla.gecko.widget.SwipeDismissListViewTouchListener; +import org.mozilla.gecko.widget.SwipeDismissListViewTouchListener.OnDismissCallback; +import org.mozilla.search.AcceptsSearchQuery.SuggestionAnimation; + +/** + * This fragment is responsible for managing the card stream. + */ +public class PreSearchFragment extends Fragment { + + private static final String LOG_TAG = "PreSearchFragment"; + + private AcceptsSearchQuery searchListener; + private SimpleCursorAdapter cursorAdapter; + + private ListView listView; + private View emptyView; + + private static final String[] PROJECTION = new String[]{ SearchHistory.QUERY, SearchHistory._ID }; + + // Limit search history query results to 10 items. + private static final int SEARCH_HISTORY_LIMIT = 10; + + private static final Uri SEARCH_HISTORY_URI = SearchHistory.CONTENT_URI.buildUpon(). + appendQueryParameter(BrowserContract.PARAM_LIMIT, String.valueOf(SEARCH_HISTORY_LIMIT)).build(); + + private static final int LOADER_ID_SEARCH_HISTORY = 1; + + public PreSearchFragment() { + // Mandatory empty constructor for Android's Fragment. + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + if (context instanceof AcceptsSearchQuery) { + searchListener = (AcceptsSearchQuery) context; + } else { + throw new ClassCastException(context.toString() + " must implement AcceptsSearchQuery."); + } + } + + @Override + public void onDetach() { + super.onDetach(); + searchListener = null; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getLoaderManager().initLoader(LOADER_ID_SEARCH_HISTORY, null, new SearchHistoryLoaderCallbacks()); + cursorAdapter = new SimpleCursorAdapter(getActivity(), R.layout.search_history_row, null, + PROJECTION, new int[]{R.id.site_name}, 0); + } + + @Override + public void onDestroy() { + super.onDestroy(); + getLoaderManager().destroyLoader(LOADER_ID_SEARCH_HISTORY); + cursorAdapter.swapCursor(null); + cursorAdapter = null; + + GeckoApplication.watchReference(getActivity(), this); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { + final View mainView = inflater.inflate(R.layout.search_fragment_pre_search, container, false); + + // Initialize listview. + listView = (ListView) mainView.findViewById(R.id.list_view); + listView.setAdapter(cursorAdapter); + listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + final String query = getQueryAtPosition(position); + if (!TextUtils.isEmpty(query)) { + final Rect startBounds = new Rect(); + view.getGlobalVisibleRect(startBounds); + + Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH, TelemetryContract.Method.SUGGESTION, "history"); + + searchListener.onSearch(query, new SuggestionAnimation() { + @Override + public Rect getStartBounds() { + return startBounds; + } + }); + } + } + }); + + // Create a ListView-specific touch listener. ListViews are given special treatment because + // by default they handle touches for their list items... i.e. they're in charge of drawing + // the pressed state (the list selector), handling list item clicks, etc. + final SwipeDismissListViewTouchListener touchListener = new SwipeDismissListViewTouchListener(listView, new OnDismissCallback() { + @Override + public void onDismiss(ListView listView, final int position) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + final String query = getQueryAtPosition(position); + final int deleted = getActivity().getContentResolver().delete( + SearchHistory.CONTENT_URI, + SearchHistory.QUERY + " = ?", + new String[] { query }); + + if (deleted < 1) { + Log.w(LOG_TAG, "Search query not deleted: " + query); + } + return null; + } + }.execute(); + } + }); + listView.setOnTouchListener(touchListener); + + // Setting this scroll listener is required to ensure that during ListView scrolling, + // we don't look for swipes. + listView.setOnScrollListener(touchListener.makeScrollListener()); + + // Setting this recycler listener is required to make sure animated views are reset. + listView.setRecyclerListener(touchListener.makeRecyclerListener()); + + return mainView; + } + + private String getQueryAtPosition(int position) { + final Cursor c = cursorAdapter.getCursor(); + if (c == null || !c.moveToPosition(position)) { + return null; + } + return c.getString(c.getColumnIndexOrThrow(SearchHistory.QUERY)); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + listView.setAdapter(null); + listView = null; + emptyView = null; + } + + private void updateUiFromCursor(Cursor c) { + if (c != null && c.getCount() > 0) { + return; + } + + if (emptyView == null) { + final ViewStub emptyViewStub = (ViewStub) getView().findViewById(R.id.empty_view_stub); + emptyView = emptyViewStub.inflate(); + + ((ImageView) emptyView.findViewById(R.id.empty_image)).setImageResource(R.drawable.icon_search_empty_firefox); + ((TextView) emptyView.findViewById(R.id.empty_title)).setText(R.string.search_empty_title); + ((TextView) emptyView.findViewById(R.id.empty_message)).setText(R.string.search_empty_message); + + listView.setEmptyView(emptyView); + } + } + + private class SearchHistoryLoaderCallbacks implements LoaderManager.LoaderCallbacks { + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new CursorLoader(getActivity(), SEARCH_HISTORY_URI, PROJECTION, null, null, + SearchHistory.DATE_LAST_VISITED + " DESC"); + } + + @Override + public void onLoadFinished(Loader loader, Cursor c) { + if (cursorAdapter != null) { + cursorAdapter.swapCursor(c); + } + updateUiFromCursor(c); + } + + @Override + public void onLoaderReset(Loader loader) { + if (cursorAdapter != null) { + cursorAdapter.swapCursor(null); + } + } + } +} diff --git a/mobile/android/search/java/org/mozilla/search/SearchActivity.java b/mobile/android/search/java/org/mozilla/search/SearchActivity.java new file mode 100644 index 000000000..b013d77b4 --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/SearchActivity.java @@ -0,0 +1,436 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search; + +import android.support.annotation.NonNull; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserContract.SearchHistory; +import org.mozilla.gecko.distribution.Distribution; +import org.mozilla.gecko.search.SearchEngine; +import org.mozilla.gecko.search.SearchEngineManager; +import org.mozilla.gecko.search.SearchEngineManager.SearchEngineCallback; +import org.mozilla.search.autocomplete.SearchBar; +import org.mozilla.search.autocomplete.SuggestionsFragment; + +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Intent; +import android.graphics.Rect; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Interpolator; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; + +/** + * The main entrance for the Android search intent. + *

+ * State management is delegated to child fragments. Fragments communicate + * with each other by passing messages through this activity. + */ +public class SearchActivity extends Locales.LocaleAwareFragmentActivity + implements AcceptsSearchQuery, SearchEngineCallback { + + private static final String LOGTAG = "GeckoSearchActivity"; + + private static final String KEY_SEARCH_STATE = "search_state"; + private static final String KEY_EDIT_STATE = "edit_state"; + private static final String KEY_QUERY = "query"; + + static enum SearchState { + PRESEARCH, + POSTSEARCH + } + + static enum EditState { + WAITING, + EDITING + } + + // Default states when activity is created. + private SearchState searchState = SearchState.PRESEARCH; + private EditState editState = EditState.WAITING; + + @NonNull + private SearchEngineManager searchEngineManager; // Contains reference to Context - DO NOT LEAK! + + // Only accessed on the main thread. + private SearchEngine engine; + + private SuggestionsFragment suggestionsFragment; + private PostSearchFragment postSearchFragment; + + private AsyncQueryHandler queryHandler; + + // Main views in layout. + private SearchBar searchBar; + private View preSearch; + private View postSearch; + + private View settingsButton; + + private View suggestions; + + private static final int SUGGESTION_TRANSITION_DURATION = 300; + private static final Interpolator SUGGESTION_TRANSITION_INTERPOLATOR = + new AccelerateDecelerateInterpolator(); + + // View used for suggestion animation. + private View animationCard; + + // Suggestion card background padding. + private int cardPaddingX; + private int cardPaddingY; + + /** + * An empty implementation of AsyncQueryHandler to avoid the "HandlerLeak" warning from Android + * Lint. See also {@see org.mozilla.gecko.util.WeakReferenceHandler}. + */ + private static class AsyncQueryHandlerImpl extends AsyncQueryHandler { + public AsyncQueryHandlerImpl(final ContentResolver that) { + super(that); + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + GeckoAppShell.ensureCrashHandling(); + + super.onCreate(savedInstanceState); + setContentView(R.layout.search_activity_main); + + suggestionsFragment = (SuggestionsFragment) getSupportFragmentManager().findFragmentById(R.id.suggestions); + postSearchFragment = (PostSearchFragment) getSupportFragmentManager().findFragmentById(R.id.postsearch); + + searchEngineManager = new SearchEngineManager(this, Distribution.init(getApplicationContext())); + searchEngineManager.setChangeCallback(this); + + // Initialize the fragments with the selected search engine. + searchEngineManager.getEngine(this); + + queryHandler = new AsyncQueryHandlerImpl(getContentResolver()); + + searchBar = (SearchBar) findViewById(R.id.search_bar); + searchBar.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + setEditState(EditState.EDITING); + } + }); + + searchBar.setTextListener(new SearchBar.TextListener() { + @Override + public void onChange(String text) { + // Only load suggestions if we're in edit mode. + if (editState == EditState.EDITING) { + suggestionsFragment.loadSuggestions(text); + } + } + + @Override + public void onSubmit(String text) { + // Don't submit an empty query. + final String trimmedQuery = text.trim(); + if (!TextUtils.isEmpty(trimmedQuery)) { + onSearch(trimmedQuery); + } + } + + @Override + public void onFocusChange(boolean hasFocus) { + setEditState(hasFocus ? EditState.EDITING : EditState.WAITING); + } + }); + + preSearch = findViewById(R.id.presearch); + postSearch = findViewById(R.id.postsearch); + + settingsButton = findViewById(R.id.settings_button); + + // Apply click handler to settings button. + settingsButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + startActivity(new Intent(SearchActivity.this, SearchPreferenceActivity.class)); + } + }); + + suggestions = findViewById(R.id.suggestions); + + animationCard = findViewById(R.id.animation_card); + + cardPaddingX = getResources().getDimensionPixelSize(R.dimen.search_row_padding); + cardPaddingY = getResources().getDimensionPixelSize(R.dimen.search_row_padding); + + if (savedInstanceState != null) { + setSearchState(SearchState.valueOf(savedInstanceState.getString(KEY_SEARCH_STATE))); + setEditState(EditState.valueOf(savedInstanceState.getString(KEY_EDIT_STATE))); + + final String query = savedInstanceState.getString(KEY_QUERY); + searchBar.setText(query); + + // If we're in the postsearch state, we need to re-do the query. + if (searchState == SearchState.POSTSEARCH) { + startSearch(query); + } + } else { + // If there isn't a state to restore, the activity will start in the presearch state, + // and we should enter editing mode to bring up the keyboard. + setEditState(EditState.EDITING); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + searchEngineManager.unregisterListeners(); + engine = null; + suggestionsFragment = null; + postSearchFragment = null; + queryHandler = null; + searchBar = null; + preSearch = null; + postSearch = null; + settingsButton = null; + suggestions = null; + animationCard = null; + } + + @Override + protected void onStart() { + super.onStart(); + Telemetry.startUISession(TelemetryContract.Session.SEARCH_ACTIVITY); + } + + @Override + protected void onStop() { + super.onStop(); + Telemetry.stopUISession(TelemetryContract.Session.SEARCH_ACTIVITY); + } + + @Override + public void onNewIntent(Intent intent) { + // Reset the activity in the presearch state if it was launched from a new intent. + setSearchState(SearchState.PRESEARCH); + + // Enter editing mode and reset the query. We must reset the query after entering + // edit mode in order for the suggestions to update. + setEditState(EditState.EDITING); + searchBar.setText(""); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putString(KEY_SEARCH_STATE, searchState.toString()); + outState.putString(KEY_EDIT_STATE, editState.toString()); + outState.putString(KEY_QUERY, searchBar.getText()); + } + + @Override + public void onSuggest(String query) { + searchBar.setText(query); + } + + @Override + public void onSearch(String query) { + onSearch(query, null); + } + + @Override + public void onSearch(String query, SuggestionAnimation suggestionAnimation) { + storeQuery(query); + + try { + //BrowserHealthRecorder.recordSearchDelayed("activity", engine.getIdentifier()); + } catch (Exception e) { + // This should never happen: it'll only throw if the + // search location is wrong. But let's not tempt fate. + Log.w(LOGTAG, "Unable to record search."); + } + + startSearch(query); + + if (suggestionAnimation != null) { + searchBar.setText(query); + // Animate the suggestion card if start bounds are specified. + animateSuggestion(suggestionAnimation); + } else { + // Otherwise immediately switch to the results view. + setEditState(EditState.WAITING); + setSearchState(SearchState.POSTSEARCH); + } + } + + @Override + public void onQueryChange(String query) { + searchBar.setText(query); + } + + private void startSearch(final String query) { + if (engine != null) { + postSearchFragment.startSearch(engine, query); + return; + } + + // engine will only be null if startSearch is called before the getEngine + // call in onCreate is completed. + searchEngineManager.getEngine(new SearchEngineCallback() { + @Override + public void execute(SearchEngine engine) { + // TODO: If engine is null, we should show an error message. + if (engine != null) { + postSearchFragment.startSearch(engine, query); + } + } + }); + } + + /** + * This method is called when we fetch the current engine in onCreate, + * as well as whenever the current engine changes. This method will only + * ever be called on the main thread. + * + * @param engine The current search engine. + */ + @Override + public void execute(SearchEngine engine) { + // TODO: If engine is null, we should show an error message. + if (engine == null) { + return; + } + this.engine = engine; + suggestionsFragment.setEngine(engine); + searchBar.setEngine(engine); + } + + /** + * Animates search suggestion item to fill the results view area. + * + * @param suggestionAnimation + */ + private void animateSuggestion(final SuggestionAnimation suggestionAnimation) { + final Rect startBounds = suggestionAnimation.getStartBounds(); + final Rect endBounds = new Rect(); + animationCard.getGlobalVisibleRect(endBounds, null); + + // Vertically translate the animated card to align with the start bounds. + final float cardStartY = startBounds.centerY() - endBounds.centerY(); + + // Account for card background padding when calculating start scale. + final float startScaleX = (float) (startBounds.width() - cardPaddingX * 2) / endBounds.width(); + final float startScaleY = (float) (startBounds.height() - cardPaddingY * 2) / endBounds.height(); + + animationCard.setVisibility(View.VISIBLE); + + final AnimatorSet set = new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(animationCard, "translationY", cardStartY, 0), + ObjectAnimator.ofFloat(animationCard, "alpha", 0.5f, 1), + ObjectAnimator.ofFloat(animationCard, "scaleX", startScaleX, 1f), + ObjectAnimator.ofFloat(animationCard, "scaleY", startScaleY, 1f) + ); + + set.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animation) { + } + + @Override + public void onAnimationEnd(Animator animation) { + // Don't do anything if the activity is destroyed before the animation ends. + if (searchEngineManager == null) { + return; + } + + setEditState(EditState.WAITING); + setSearchState(SearchState.POSTSEARCH); + + // We need to manually clear the animation for the views to be hidden on gingerbread. + animationCard.clearAnimation(); + animationCard.setVisibility(View.INVISIBLE); + } + + @Override + public void onAnimationCancel(Animator animation) { + } + + @Override + public void onAnimationRepeat(Animator animation) { + } + }); + + set.setDuration(SUGGESTION_TRANSITION_DURATION); + set.setInterpolator(SUGGESTION_TRANSITION_INTERPOLATOR); + + set.start(); + } + + private void setEditState(EditState editState) { + if (this.editState == editState) { + return; + } + this.editState = editState; + + updateSettingsButtonVisibility(); + + searchBar.setActive(editState == EditState.EDITING); + suggestions.setVisibility(editState == EditState.EDITING ? View.VISIBLE : View.INVISIBLE); + } + + private void setSearchState(SearchState searchState) { + if (this.searchState == searchState) { + return; + } + this.searchState = searchState; + + updateSettingsButtonVisibility(); + + preSearch.setVisibility(searchState == SearchState.PRESEARCH ? View.VISIBLE : View.INVISIBLE); + postSearch.setVisibility(searchState == SearchState.POSTSEARCH ? View.VISIBLE : View.INVISIBLE); + } + + private void updateSettingsButtonVisibility() { + // Show button on launch screen when keyboard is down. + if (searchState == SearchState.PRESEARCH && editState == EditState.WAITING) { + settingsButton.setVisibility(View.VISIBLE); + } else { + settingsButton.setVisibility(View.INVISIBLE); + } + } + + @Override + public void onBackPressed() { + if (editState == EditState.EDITING) { + setEditState(EditState.WAITING); + } else if (searchState == SearchState.POSTSEARCH) { + setSearchState(SearchState.PRESEARCH); + } else { + super.onBackPressed(); + } + } + + /** + * Store the search query in Fennec's search history database. + */ + private void storeQuery(String query) { + final ContentValues cv = new ContentValues(); + cv.put(SearchHistory.QUERY, query); + // Setting 0 for the token, since we only have one type of insert. + // Setting null for the cookie, since we don't handle the result of the insert. + queryHandler.startInsert(0, null, SearchHistory.CONTENT_URI, cv); + } +} diff --git a/mobile/android/search/java/org/mozilla/search/SearchPreferenceActivity.java b/mobile/android/search/java/org/mozilla/search/SearchPreferenceActivity.java new file mode 100644 index 000000000..6d33da130 --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/SearchPreferenceActivity.java @@ -0,0 +1,118 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserContract; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceActivity; +import android.util.Log; +import android.view.MenuItem; +import android.widget.Toast; + +/** + * This activity allows users to modify the settings for the search activity. + * + * A note on implementation: At the moment, we don't have tablet-specific designs. + * Therefore, this implementation uses the old-style PreferenceActivity. When + * we start optimizing for tablets, we can migrate to Fennec's PreferenceFragment + * implementation. + * + * TODO: Change this to PreferenceFragment when we stop supporting devices older than SDK 11. + */ +public class SearchPreferenceActivity extends PreferenceActivity { + + private static final String LOG_TAG = "SearchPreferenceActivity"; + + public static final String PREF_CLEAR_HISTORY_KEY = "search.not_a_preference.clear_history"; + + @Override + @SuppressWarnings("deprecation") + protected void onCreate(Bundle savedInstanceState) { + Locales.initializeLocale(getApplicationContext()); + super.onCreate(savedInstanceState); + + getPreferenceManager().setSharedPreferencesName(GeckoSharedPrefs.APP_PREFS_NAME); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + if (getActionBar() != null) { + getActionBar().setDisplayHomeAsUpEnabled(true); + } + } + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + setupPrefsScreen(); + } + + @SuppressWarnings("deprecation") + private void setupPrefsScreen() { + addPreferencesFromResource(R.xml.search_preferences); + + // Attach click listener to clear history button. + final Preference clearHistoryButton = findPreference(PREF_CLEAR_HISTORY_KEY); + clearHistoryButton.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(SearchPreferenceActivity.this); + dialogBuilder.setNegativeButton(android.R.string.cancel, null); + dialogBuilder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Telemetry.sendUIEvent(TelemetryContract.Event.SANITIZE, TelemetryContract.Method.MENU, "search-history"); + clearHistory(); + } + }); + dialogBuilder.setMessage(R.string.pref_clearHistory_dialogMessage); + dialogBuilder.show(); + return false; + } + }); + } + + private void clearHistory() { + final AsyncTask clearHistoryTask = new AsyncTask() { + @Override + protected Boolean doInBackground(Void... params) { + final int numDeleted = getContentResolver().delete( + BrowserContract.SearchHistory.CONTENT_URI, null, null); + return numDeleted >= 0; + } + + @Override + protected void onPostExecute(Boolean success) { + if (success) { + getContentResolver().notifyChange(BrowserContract.SearchHistory.CONTENT_URI, null); + Toast.makeText(SearchPreferenceActivity.this, SearchPreferenceActivity.this.getResources() + .getString(R.string.pref_clearHistory_confirmation), Toast.LENGTH_SHORT).show(); + } else { + Log.e(LOG_TAG, "Error clearing search history."); + } + } + }; + clearHistoryTask.execute(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return false; + } +} diff --git a/mobile/android/search/java/org/mozilla/search/SearchWidget.java b/mobile/android/search/java/org/mozilla/search/SearchWidget.java new file mode 100644 index 000000000..8f69cc22c --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/SearchWidget.java @@ -0,0 +1,135 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.appwidget.AppWidgetProviderInfo; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.widget.RemoteViews; + +/* Provides a really simple widget with two buttons, one to launch Fennec + * and one to launch the search activity. All intents are actually sent back + * here and then forwarded on to start the real activity. */ +public class SearchWidget extends AppWidgetProvider { + final private static String LOGTAG = "GeckoSearchWidget"; + + final public static String ACTION_LAUNCH_BROWSER = "org.mozilla.widget.LAUNCH_BROWSER"; + final public static String ACTION_LAUNCH_SEARCH = "org.mozilla.widget.LAUNCH_SEARCH"; + final public static String ACTION_LAUNCH_NEW_TAB = "org.mozilla.widget.LAUNCH_NEW_TAB"; + + @TargetApi(16) + @Override + public void onUpdate(final Context context, final AppWidgetManager manager, final int[] ids) { + for (int id : ids) { + final Bundle bundle; + if (AppConstants.Versions.feature16Plus) { + bundle = manager.getAppWidgetOptions(id); + } else { + bundle = null; + } + addView(manager, context, id, bundle); + } + + super.onUpdate(context, manager, ids); + } + + @TargetApi(16) + @Override + public void onAppWidgetOptionsChanged(final Context context, + final AppWidgetManager manager, + final int id, + final Bundle options) { + addView(manager, context, id, options); + if (AppConstants.Versions.feature16Plus) { + super.onAppWidgetOptionsChanged(context, manager, id, options); + } + } + + @Override + public void onReceive(final Context context, final Intent intent) { + // This will hold the intent to redispatch. + final Intent redirect; + switch (intent.getAction()) { + case ACTION_LAUNCH_BROWSER: + redirect = buildRedirectIntent(Intent.ACTION_MAIN, + context.getPackageName(), + AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS, + intent); + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, + TelemetryContract.Method.WIDGET, "browser"); + break; + case ACTION_LAUNCH_NEW_TAB: + redirect = buildRedirectIntent(Intent.ACTION_VIEW, + context.getPackageName(), + AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS, + intent); + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, + TelemetryContract.Method.WIDGET, "new-tab"); + break; + case ACTION_LAUNCH_SEARCH: + redirect = buildRedirectIntent(Intent.ACTION_VIEW, + context.getPackageName(), + AppConstants.MOZ_ANDROID_SEARCH_INTENT_CLASS, + intent); + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, + TelemetryContract.Method.WIDGET, "search"); + break; + default: + redirect = null; + } + + if (redirect != null) { + context.startActivity(redirect); + } + + super.onReceive(context, intent); + } + + // Utility to create the view for this widget and attach any event listeners to it + private void addView(final AppWidgetManager manager, final Context context, final int id, final Bundle options) { + final RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.search_widget); + + addClickIntent(context, views, R.id.search_button, ACTION_LAUNCH_SEARCH); + addClickIntent(context, views, R.id.new_tab_button, ACTION_LAUNCH_NEW_TAB); + // Clicking the logo also launches the browser + addClickIntent(context, views, R.id.logo_button, ACTION_LAUNCH_BROWSER); + + manager.updateAppWidget(id, views); + } + + // Utility for adding a pending intent to be fired when a View is clicked. + private void addClickIntent(final Context context, final RemoteViews views, final int viewId, final String action) { + final Intent intent = new Intent(context, SearchWidget.class); + intent.setAction(action); + intent.setData(Uri.parse(AboutPages.HOME)); + final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0); + views.setOnClickPendingIntent(viewId, pendingIntent); + } + + // Utility for building an intent to be redispatched (i.e. to launch the browser or the search intent). + private Intent buildRedirectIntent(final String action, final String pkg, final String className, final Intent source) { + final Intent activity = new Intent(action); + if (pkg != null && className != null) { + activity.setClassName(pkg, className); + } + activity.setData(source.getData()); + activity.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return activity; + } + +} diff --git a/mobile/android/search/java/org/mozilla/search/autocomplete/AutoCompleteAdapter.java b/mobile/android/search/java/org/mozilla/search/autocomplete/AutoCompleteAdapter.java new file mode 100644 index 000000000..5a0cc8fb6 --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/autocomplete/AutoCompleteAdapter.java @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search.autocomplete; + +import java.util.List; + +import org.mozilla.gecko.R; +import org.mozilla.search.AcceptsSearchQuery; +import org.mozilla.search.autocomplete.SuggestionsFragment.Suggestion; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +/** + * The adapter that is used to populate the autocomplete rows. + */ +class AutoCompleteAdapter extends ArrayAdapter { + + private final AcceptsSearchQuery searchListener; + + private final LayoutInflater inflater; + + public AutoCompleteAdapter(Context context) { + // Uses '0' for the template id since we are overriding getView + // and supplying our own view. + super(context, 0); + + if (context instanceof AcceptsSearchQuery) { + searchListener = (AcceptsSearchQuery) context; + } else { + throw new ClassCastException(context.toString() + " must implement AcceptsSearchQuery."); + } + + // Disable notifying on change. We will notify ourselves in update. + setNotifyOnChange(false); + + inflater = LayoutInflater.from(context); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = inflater.inflate(R.layout.search_suggestions_row, null); + } + + final Suggestion suggestion = getItem(position); + + final TextView textView = (TextView) convertView.findViewById(R.id.auto_complete_row_text); + textView.setText(suggestion.display); + + final View jumpButton = convertView.findViewById(R.id.auto_complete_row_jump_button); + jumpButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + searchListener.onSuggest(suggestion.value); + } + }); + + return convertView; + } + + /** + * Updates adapter content with new list of search suggestions. + * + * @param suggestions List of search suggestions. + */ + public void update(List suggestions) { + clear(); + if (suggestions != null) { + for (Suggestion s : suggestions) { + add(s); + } + } + notifyDataSetChanged(); + } +} diff --git a/mobile/android/search/java/org/mozilla/search/autocomplete/SearchBar.java b/mobile/android/search/java/org/mozilla/search/autocomplete/SearchBar.java new file mode 100644 index 000000000..6225c050b --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/autocomplete/SearchBar.java @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search.autocomplete; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.search.SearchEngine; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +public class SearchBar extends FrameLayout { + + private final EditText editText; + private final ImageButton clearButton; + private final ImageView engineIcon; + + private final Drawable focusedBackground; + private final Drawable defaultBackground; + + private final InputMethodManager inputMethodManager; + + private TextListener listener; + + private boolean active; + + public interface TextListener { + public void onChange(String text); + public void onSubmit(String text); + public void onFocusChange(boolean hasFocus); + } + + // Deprecation warnings suppressed to allow building with API level 22 + @SuppressWarnings("deprecation") + public SearchBar(Context context, AttributeSet attrs) { + super(context, attrs); + + LayoutInflater.from(context).inflate(R.layout.search_bar, this); + + editText = (EditText) findViewById(R.id.edit_text); + editText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + if (listener != null) { + listener.onChange(s.toString()); + } + + updateClearButtonVisibility(); + } + }); + + // Attach a listener for the "search" key on the keyboard. + editText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (listener != null && + (actionId == EditorInfo.IME_ACTION_UNSPECIFIED || actionId == EditorInfo.IME_ACTION_SEARCH)) { + // The user searched without using search engine suggestions. + Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH, TelemetryContract.Method.ACTIONBAR, "text"); + listener.onSubmit(v.getText().toString()); + return true; + } + return false; + } + }); + + editText.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (listener != null) { + listener.onFocusChange(hasFocus); + } + } + }); + + clearButton = (ImageButton) findViewById(R.id.clear_button); + clearButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + editText.setText(""); + } + }); + engineIcon = (ImageView) findViewById(R.id.engine_icon); + + focusedBackground = getResources().getDrawable(R.drawable.edit_text_focused); + defaultBackground = getResources().getDrawable(R.drawable.edit_text_default); + + inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + public void setText(String text) { + editText.setText(text); + + // Move cursor to end of search input. + editText.setSelection(text.length()); + } + + public String getText() { + return editText.getText().toString(); + } + + public void setEngine(SearchEngine engine) { + final String iconURL = engine.getIconURL(); + final Bitmap bitmap = BitmapUtils.getBitmapFromDataURI(iconURL); + final BitmapDrawable d = new BitmapDrawable(getResources(), bitmap); + engineIcon.setImageDrawable(d); + engineIcon.setContentDescription(engine.getName()); + + // Update the focused background color. + int color = BitmapUtils.getDominantColor(bitmap); + + // BitmapUtils#getDominantColor ignores black and white pixels, but it will + // return white if no dominant color was found. We don't want to create a + // white underline for the search bar, so we default to black instead. + if (color == Color.WHITE) { + color = Color.BLACK; + } + focusedBackground.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)); + + editText.setHint(getResources().getString(R.string.search_bar_hint, engine.getName())); + } + + @SuppressWarnings("deprecation") + public void setActive(boolean active) { + if (this.active == active) { + return; + } + this.active = active; + + updateClearButtonVisibility(); + + editText.setFocusable(active); + editText.setFocusableInTouchMode(active); + + final int leftDrawable = active ? R.drawable.search_icon_active : R.drawable.search_icon_inactive; + editText.setCompoundDrawablesWithIntrinsicBounds(leftDrawable, 0, 0, 0); + + // We can't use a selector drawable because we apply a color filter to the focused + // background at run time. + // TODO: setBackgroundDrawable is deprecated in API level 16 + editText.setBackgroundDrawable(active ? focusedBackground : defaultBackground); + + if (active) { + editText.requestFocus(); + inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT); + } else { + editText.clearFocus(); + inputMethodManager.hideSoftInputFromWindow(editText.getWindowToken(), 0); + } + } + + private void updateClearButtonVisibility() { + // Only show the clear button when there is text in the input. + final boolean visible = active && (editText.getText().length() > 0); + clearButton.setVisibility(visible ? View.VISIBLE : View.GONE); + engineIcon.setVisibility(visible ? View.GONE : View.VISIBLE); + } + + public void setTextListener(TextListener listener) { + this.listener = listener; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent e) { + // When the view is active, pass touch events to child views. + // Otherwise, intercept touch events to allow click listeners on the view to + // fire no matter where the user clicks. + return !active; + } +} diff --git a/mobile/android/search/java/org/mozilla/search/autocomplete/SuggestionsFragment.java b/mobile/android/search/java/org/mozilla/search/autocomplete/SuggestionsFragment.java new file mode 100644 index 000000000..ce935e437 --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/autocomplete/SuggestionsFragment.java @@ -0,0 +1,263 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search.autocomplete; + +import java.util.ArrayList; +import java.util.List; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.R; +import org.mozilla.gecko.search.SearchEngine; +import org.mozilla.gecko.SuggestClient; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.search.AcceptsSearchQuery; +import org.mozilla.search.AcceptsSearchQuery.SuggestionAnimation; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.AsyncTaskLoader; +import android.support.v4.content.Loader; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ListView; + +/** + * A fragment to show search suggestions. + */ +public class SuggestionsFragment extends Fragment { + + private static final String LOG_TAG = "SuggestionsFragment"; + + private static final int LOADER_ID_SUGGESTION = 0; + private static final String KEY_SEARCH_TERM = "search_term"; + + // Timeout for the suggestion client to respond + private static final int SUGGESTION_TIMEOUT = 3000; + + // Number of search suggestions to show. + private static final int SUGGESTION_MAX = 5; + + public static final String GECKO_SEARCH_TERMS_URL_PARAM = "__searchTerms__"; + + private AcceptsSearchQuery searchListener; + + // Suggest client gets setup outside of the normal fragment lifecycle, therefore + // clients should ensure that this isn't null before using it. + private SuggestClient suggestClient; + private SuggestionLoaderCallbacks suggestionLoaderCallbacks; + + private AutoCompleteAdapter autoCompleteAdapter; + + // Holds the list of search suggestions. + private ListView suggestionsList; + + public SuggestionsFragment() { + // Required empty public constructor + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + if (context instanceof AcceptsSearchQuery) { + searchListener = (AcceptsSearchQuery) context; + } else { + throw new ClassCastException(context.toString() + " must implement AcceptsSearchQuery."); + } + + suggestionLoaderCallbacks = new SuggestionLoaderCallbacks(); + autoCompleteAdapter = new AutoCompleteAdapter(context); + } + + @Override + public void onDetach() { + super.onDetach(); + + searchListener = null; + suggestionLoaderCallbacks = null; + autoCompleteAdapter = null; + suggestClient = null; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + suggestionsList = (ListView) inflater.inflate(R.layout.search_sugestions, container, false); + suggestionsList.setAdapter(autoCompleteAdapter); + + // Attach listener for tapping on a suggestion. + suggestionsList.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + final Suggestion suggestion = (Suggestion) suggestionsList.getItemAtPosition(position); + + final Rect startBounds = new Rect(); + view.getGlobalVisibleRect(startBounds); + + // The user tapped on a suggestion from the search engine. + Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH, TelemetryContract.Method.SUGGESTION, position); + + searchListener.onSearch(suggestion.value, new SuggestionAnimation() { + @Override + public Rect getStartBounds() { + return startBounds; + } + }); + } + }); + + return suggestionsList; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + if (null != suggestionsList) { + suggestionsList.setOnItemClickListener(null); + suggestionsList.setAdapter(null); + suggestionsList = null; + } + } + + public void setEngine(SearchEngine engine) { + suggestClient = new SuggestClient(getActivity(), engine.getSuggestionTemplate(GECKO_SEARCH_TERMS_URL_PARAM), + SUGGESTION_TIMEOUT, SUGGESTION_MAX, true); + } + + public void loadSuggestions(String query) { + final Bundle args = new Bundle(); + args.putString(KEY_SEARCH_TERM, query); + final LoaderManager loaderManager = getLoaderManager(); + + // Ensure that we don't try to restart a loader that doesn't exist. This becomes + // an issue because SuggestionLoaderCallbacks.onCreateLoader can return null + // as a loader if we don't have a suggestClient available yet. + if (loaderManager.getLoader(LOADER_ID_SUGGESTION) == null) { + loaderManager.initLoader(LOADER_ID_SUGGESTION, args, suggestionLoaderCallbacks); + } else { + loaderManager.restartLoader(LOADER_ID_SUGGESTION, args, suggestionLoaderCallbacks); + } + } + + public static class Suggestion { + + public final String value; + public final SpannableString display; + public final ForegroundColorSpan colorSpan; + + public Suggestion(String value, String searchTerm, int suggestionHighlightColor) { + this.value = value; + + display = new SpannableString(value); + + colorSpan = new ForegroundColorSpan(suggestionHighlightColor); + + // Highlight mixed-case matches. + final int start = value.toLowerCase().indexOf(searchTerm.toLowerCase()); + if (start >= 0) { + display.setSpan(colorSpan, start, start + searchTerm.length(), 0); + } + } + } + + private class SuggestionLoaderCallbacks implements LoaderManager.LoaderCallbacks> { + @Override + public Loader> onCreateLoader(int id, Bundle args) { + // We drop the user's search if suggestclient isn't ready. This happens if the + // user is really fast and starts typing before we can read shared prefs. + if (suggestClient != null) { + return new SuggestionAsyncLoader(getActivity(), suggestClient, args.getString(KEY_SEARCH_TERM)); + } + Log.e(LOG_TAG, "Autocomplete setup failed; suggestClient not ready yet."); + return null; + } + + @Override + public void onLoadFinished(Loader> loader, List suggestions) { + // Only show the ListView if there are suggestions in it. + if (suggestions.size() > 0) { + autoCompleteAdapter.update(suggestions); + suggestionsList.setVisibility(View.VISIBLE); + } else { + suggestionsList.setVisibility(View.INVISIBLE); + } + } + + @Override + public void onLoaderReset(Loader> loader) { } + } + + private static class SuggestionAsyncLoader extends AsyncTaskLoader> { + private final SuggestClient suggestClient; + private final String searchTerm; + private List suggestions; + private final int suggestionHighlightColor; + + public SuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) { + super(context); + this.suggestClient = suggestClient; + this.searchTerm = searchTerm; + this.suggestions = null; + + // Color of search term match in search suggestion + suggestionHighlightColor = ContextCompat.getColor(context, R.color.suggestion_highlight); + } + + @Override + public List loadInBackground() { + final List values = suggestClient.query(searchTerm); + + final List result = new ArrayList(values.size()); + for (String value : values) { + result.add(new Suggestion(value, searchTerm, suggestionHighlightColor)); + } + + return result; + } + + @Override + public void deliverResult(List suggestions) { + this.suggestions = suggestions; + + if (isStarted()) { + super.deliverResult(suggestions); + } + } + + @Override + protected void onStartLoading() { + if (suggestions != null) { + deliverResult(suggestions); + } + + if (takeContentChanged() || suggestions == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + protected void onReset() { + super.onReset(); + + onStopLoading(); + suggestions = null; + } + } +} diff --git a/mobile/android/search/java/org/mozilla/search/ui/BackCaptureEditText.java b/mobile/android/search/java/org/mozilla/search/ui/BackCaptureEditText.java new file mode 100644 index 000000000..727ad8105 --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/ui/BackCaptureEditText.java @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.widget.EditText; + +/** + * An EditText subclass that loses focus when the keyboard + * is dismissed. + */ +public class BackCaptureEditText extends EditText { + public BackCaptureEditText(Context context) { + super(context); + } + + public BackCaptureEditText(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BackCaptureEditText(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + clearFocus(); + } + return super.onKeyPreIme(keyCode, event); + } +} diff --git a/mobile/android/search/java/org/mozilla/search/ui/FacetBar.java b/mobile/android/search/java/org/mozilla/search/ui/FacetBar.java new file mode 100644 index 000000000..7fcf3dc9b --- /dev/null +++ b/mobile/android/search/java/org/mozilla/search/ui/FacetBar.java @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.search.ui; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.widget.RadioButton; +import android.widget.RadioGroup; + +public class FacetBar extends RadioGroup { + + // Ensure facets have equal width and match the bar's height. Supplying these + // in styles.xml/FacetButtonStyle does not work. See: + // http://stackoverflow.com/questions/24213193/android-ignores-layout-weight-parameter-from-styles-xml + private static final RadioGroup.LayoutParams FACET_LAYOUT_PARAMS = + new RadioGroup.LayoutParams(0, LayoutParams.MATCH_PARENT, 1.0f); + + // A loud default color to make it obvious that setUnderlineColor should be called. + private int underlineColor = Color.RED; + + // Used for assigning unique view ids when facet buttons are being created. + private int nextButtonId = 0; + + public FacetBar(Context context, AttributeSet attrs) { + super(context, attrs); + } + + /** + * Add a new button to the facet bar. + * + * @param facetName The text to be used in the button. + */ + public void addFacet(String facetName) { + addFacet(facetName, false); + } + + /** + * Add a new button to the facet bar. + * + * @param facetName The text to be used in the button. + * @param checked Whether the button should be checked. If true, the + * onCheckChange listener *will* be fired. + */ + public void addFacet(String facetName, boolean checked) { + final FacetButton button = new FacetButton(getContext(), facetName, underlineColor); + + // The ids are used internally by RadioGroup to manage which button is + // currently checked. Since we are programmatically creating the buttons, + // we need to manually assign an id. + button.setId(nextButtonId++); + + // Ensure the buttons are equally spaced. + button.setLayoutParams(FACET_LAYOUT_PARAMS); + + // If true, this will fire the onCheckChange listener. + button.setChecked(checked); + + addView(button); + } + + /** + * Update the brand color for all of the buttons. + */ + public void setUnderlineColor(int underlineColor) { + this.underlineColor = underlineColor; + + if (getChildCount() > 0) { + for (int i = 0; i < getChildCount(); i++) { + ((FacetButton) getChildAt(i)).setUnderlineColor(underlineColor); + } + } + } + + /** + * A custom TextView that includes a bottom border. The bottom border + * can have a custom color and thickness. + */ + private static class FacetButton extends RadioButton { + + private final Paint underlinePaint = new Paint(); + + public FacetButton(Context context, String text, int color) { + super(context, null, R.attr.facetButtonStyle); + + setText(text); + + underlinePaint.setStyle(Paint.Style.STROKE); + underlinePaint.setStrokeWidth(getResources().getDimension(R.dimen.facet_button_underline_thickness)); + underlinePaint.setColor(color); + } + + @Override + public void setChecked(boolean checked) { + super.setChecked(checked); + + // Force the button to redraw to update the underline state. + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (isChecked()) { + // Translate the line upward so that it isn't clipped by the button's boundary. + // We divide by 2 since, without offset, the line would be drawn with its + // midpoint at the bottom of the button -- half of the stroke going up, + // and half of the stroke getting clipped. + final float yPos = getHeight() - underlinePaint.getStrokeWidth() / 2; + canvas.drawLine(0, yPos, getWidth(), yPos, underlinePaint); + } + } + + public void setUnderlineColor(int color) { + underlinePaint.setColor(color); + } + } +} -- cgit v1.2.3