summaryrefslogtreecommitdiffstats
path: root/mobile/android/search/java
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /mobile/android/search/java
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/search/java')
-rw-r--r--mobile/android/search/java/org/mozilla/search/AcceptsSearchQuery.java48
-rw-r--r--mobile/android/search/java/org/mozilla/search/Constants.java20
-rw-r--r--mobile/android/search/java/org/mozilla/search/PostSearchFragment.java243
-rw-r--r--mobile/android/search/java/org/mozilla/search/PreSearchFragment.java218
-rw-r--r--mobile/android/search/java/org/mozilla/search/SearchActivity.java436
-rw-r--r--mobile/android/search/java/org/mozilla/search/SearchPreferenceActivity.java118
-rw-r--r--mobile/android/search/java/org/mozilla/search/SearchWidget.java135
-rw-r--r--mobile/android/search/java/org/mozilla/search/autocomplete/AutoCompleteAdapter.java82
-rw-r--r--mobile/android/search/java/org/mozilla/search/autocomplete/SearchBar.java201
-rw-r--r--mobile/android/search/java/org/mozilla/search/autocomplete/SuggestionsFragment.java263
-rw-r--r--mobile/android/search/java/org/mozilla/search/ui/BackCaptureEditText.java36
-rw-r--r--mobile/android/search/java/org/mozilla/search/ui/FacetBar.java124
12 files changed, 1924 insertions, 0 deletions
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<Void, Void, Void>() {
+ @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<Cursor> {
+ @Override
+ public Loader<Cursor> 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<Cursor> loader, Cursor c) {
+ if (cursorAdapter != null) {
+ cursorAdapter.swapCursor(c);
+ }
+ updateUiFromCursor(c);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> 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.
+ * <p/>
+ * 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<Void, Void, Boolean> clearHistoryTask = new AsyncTask<Void, Void, Boolean>() {
+ @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<Suggestion> {
+
+ 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<Suggestion> 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<List<Suggestion>> {
+ @Override
+ public Loader<List<Suggestion>> 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<List<Suggestion>> loader, List<Suggestion> 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<List<Suggestion>> loader) { }
+ }
+
+ private static class SuggestionAsyncLoader extends AsyncTaskLoader<List<Suggestion>> {
+ private final SuggestClient suggestClient;
+ private final String searchTerm;
+ private List<Suggestion> 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<Suggestion> loadInBackground() {
+ final List<String> values = suggestClient.query(searchTerm);
+
+ final List<Suggestion> result = new ArrayList<Suggestion>(values.size());
+ for (String value : values) {
+ result.add(new Suggestion(value, searchTerm, suggestionHighlightColor));
+ }
+
+ return result;
+ }
+
+ @Override
+ public void deliverResult(List<Suggestion> 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);
+ }
+ }
+}