1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/base/home/BrowserSearch.java Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,1023 @@ 1.4 +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- 1.5 + * This Source Code Form is subject to the terms of the Mozilla Public 1.6 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.7 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.8 + 1.9 +package org.mozilla.gecko.home; 1.10 + 1.11 +import org.mozilla.gecko.GeckoAppShell; 1.12 +import org.mozilla.gecko.GeckoEvent; 1.13 +import org.mozilla.gecko.PrefsHelper; 1.14 +import org.mozilla.gecko.R; 1.15 +import org.mozilla.gecko.Tab; 1.16 +import org.mozilla.gecko.Tabs; 1.17 +import org.mozilla.gecko.Telemetry; 1.18 +import org.mozilla.gecko.TelemetryContract; 1.19 +import org.mozilla.gecko.db.BrowserDB.URLColumns; 1.20 +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; 1.21 +import org.mozilla.gecko.home.SearchEngine; 1.22 +import org.mozilla.gecko.home.SearchLoader.SearchCursorLoader; 1.23 +import org.mozilla.gecko.mozglue.RobocopTarget; 1.24 +import org.mozilla.gecko.toolbar.AutocompleteHandler; 1.25 +import org.mozilla.gecko.util.GeckoEventListener; 1.26 +import org.mozilla.gecko.util.StringUtils; 1.27 +import org.mozilla.gecko.util.ThreadUtils; 1.28 + 1.29 +import org.json.JSONArray; 1.30 +import org.json.JSONException; 1.31 +import org.json.JSONObject; 1.32 + 1.33 +import android.app.Activity; 1.34 +import android.content.Context; 1.35 +import android.database.Cursor; 1.36 +import android.net.Uri; 1.37 +import android.os.Bundle; 1.38 +import android.support.v4.app.LoaderManager.LoaderCallbacks; 1.39 +import android.support.v4.content.AsyncTaskLoader; 1.40 +import android.support.v4.content.Loader; 1.41 +import android.text.TextUtils; 1.42 +import android.util.AttributeSet; 1.43 +import android.util.Log; 1.44 +import android.view.LayoutInflater; 1.45 +import android.view.MotionEvent; 1.46 +import android.view.View; 1.47 +import android.view.View.OnClickListener; 1.48 +import android.view.ViewGroup; 1.49 +import android.view.ViewStub; 1.50 +import android.view.WindowManager; 1.51 +import android.view.WindowManager.LayoutParams; 1.52 +import android.view.animation.AccelerateInterpolator; 1.53 +import android.view.animation.Animation; 1.54 +import android.view.animation.TranslateAnimation; 1.55 +import android.widget.AdapterView; 1.56 +import android.widget.LinearLayout; 1.57 +import android.widget.ListView; 1.58 +import android.widget.TextView; 1.59 + 1.60 +import java.util.ArrayList; 1.61 +import java.util.EnumSet; 1.62 + 1.63 +/** 1.64 + * Fragment that displays frecency search results in a ListView. 1.65 + */ 1.66 +public class BrowserSearch extends HomeFragment 1.67 + implements GeckoEventListener { 1.68 + // Logging tag name 1.69 + private static final String LOGTAG = "GeckoBrowserSearch"; 1.70 + 1.71 + // Cursor loader ID for search query 1.72 + private static final int LOADER_ID_SEARCH = 0; 1.73 + 1.74 + // AsyncTask loader ID for suggestion query 1.75 + private static final int LOADER_ID_SUGGESTION = 1; 1.76 + 1.77 + // Timeout for the suggestion client to respond 1.78 + private static final int SUGGESTION_TIMEOUT = 3000; 1.79 + 1.80 + // Maximum number of results returned by the suggestion client 1.81 + private static final int SUGGESTION_MAX = 3; 1.82 + 1.83 + // The maximum number of rows deep in a search we'll dig 1.84 + // for an autocomplete result 1.85 + private static final int MAX_AUTOCOMPLETE_SEARCH = 20; 1.86 + 1.87 + // Length of https:// + 1 required to make autocomplete 1.88 + // fill in the domain, for both http:// and https:// 1.89 + private static final int HTTPS_PREFIX_LENGTH = 9; 1.90 + 1.91 + // Duration for fade-in animation 1.92 + private static final int ANIMATION_DURATION = 250; 1.93 + 1.94 + // Holds the current search term to use in the query 1.95 + private volatile String mSearchTerm; 1.96 + 1.97 + // Adapter for the list of search results 1.98 + private SearchAdapter mAdapter; 1.99 + 1.100 + // The view shown by the fragment 1.101 + private LinearLayout mView; 1.102 + 1.103 + // The list showing search results 1.104 + private HomeListView mList; 1.105 + 1.106 + // Client that performs search suggestion queries 1.107 + private volatile SuggestClient mSuggestClient; 1.108 + 1.109 + // List of search engines from gecko 1.110 + private ArrayList<SearchEngine> mSearchEngines; 1.111 + 1.112 + // Whether search suggestions are enabled or not 1.113 + private boolean mSuggestionsEnabled; 1.114 + 1.115 + // Callbacks used for the search loader 1.116 + private CursorLoaderCallbacks mCursorLoaderCallbacks; 1.117 + 1.118 + // Callbacks used for the search suggestion loader 1.119 + private SuggestionLoaderCallbacks mSuggestionLoaderCallbacks; 1.120 + 1.121 + // Autocomplete handler used when filtering results 1.122 + private AutocompleteHandler mAutocompleteHandler; 1.123 + 1.124 + // On URL open listener 1.125 + private OnUrlOpenListener mUrlOpenListener; 1.126 + 1.127 + // On search listener 1.128 + private OnSearchListener mSearchListener; 1.129 + 1.130 + // On edit suggestion listener 1.131 + private OnEditSuggestionListener mEditSuggestionListener; 1.132 + 1.133 + // Whether the suggestions will fade in when shown 1.134 + private boolean mAnimateSuggestions; 1.135 + 1.136 + // Opt-in prompt view for search suggestions 1.137 + private View mSuggestionsOptInPrompt; 1.138 + 1.139 + public interface OnSearchListener { 1.140 + public void onSearch(SearchEngine engine, String text); 1.141 + } 1.142 + 1.143 + public interface OnEditSuggestionListener { 1.144 + public void onEditSuggestion(String suggestion); 1.145 + } 1.146 + 1.147 + public static BrowserSearch newInstance() { 1.148 + BrowserSearch browserSearch = new BrowserSearch(); 1.149 + 1.150 + final Bundle args = new Bundle(); 1.151 + args.putBoolean(HomePager.CAN_LOAD_ARG, true); 1.152 + browserSearch.setArguments(args); 1.153 + 1.154 + return browserSearch; 1.155 + } 1.156 + 1.157 + public BrowserSearch() { 1.158 + mSearchTerm = ""; 1.159 + } 1.160 + 1.161 + @Override 1.162 + public void onAttach(Activity activity) { 1.163 + super.onAttach(activity); 1.164 + 1.165 + try { 1.166 + mUrlOpenListener = (OnUrlOpenListener) activity; 1.167 + } catch (ClassCastException e) { 1.168 + throw new ClassCastException(activity.toString() 1.169 + + " must implement BrowserSearch.OnUrlOpenListener"); 1.170 + } 1.171 + 1.172 + try { 1.173 + mSearchListener = (OnSearchListener) activity; 1.174 + } catch (ClassCastException e) { 1.175 + throw new ClassCastException(activity.toString() 1.176 + + " must implement BrowserSearch.OnSearchListener"); 1.177 + } 1.178 + 1.179 + try { 1.180 + mEditSuggestionListener = (OnEditSuggestionListener) activity; 1.181 + } catch (ClassCastException e) { 1.182 + throw new ClassCastException(activity.toString() 1.183 + + " must implement BrowserSearch.OnEditSuggestionListener"); 1.184 + } 1.185 + } 1.186 + 1.187 + @Override 1.188 + public void onDetach() { 1.189 + super.onDetach(); 1.190 + 1.191 + mAutocompleteHandler = null; 1.192 + mUrlOpenListener = null; 1.193 + mSearchListener = null; 1.194 + mEditSuggestionListener = null; 1.195 + } 1.196 + 1.197 + @Override 1.198 + public void onCreate(Bundle savedInstanceState) { 1.199 + super.onCreate(savedInstanceState); 1.200 + 1.201 + mSearchEngines = new ArrayList<SearchEngine>(); 1.202 + } 1.203 + 1.204 + @Override 1.205 + public void onDestroy() { 1.206 + super.onDestroy(); 1.207 + 1.208 + mSearchEngines = null; 1.209 + } 1.210 + 1.211 + @Override 1.212 + public void onStart() { 1.213 + super.onStart(); 1.214 + 1.215 + // Adjusting the window size when showing the keyboard results in the underlying 1.216 + // activity being painted when the keyboard is hidden (bug 933422). This can be 1.217 + // prevented by not resizing the window. 1.218 + getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); 1.219 + } 1.220 + 1.221 + @Override 1.222 + public void onStop() { 1.223 + super.onStop(); 1.224 + 1.225 + getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); 1.226 + } 1.227 + 1.228 + @Override 1.229 + public void onResume() { 1.230 + super.onResume(); 1.231 + 1.232 + Telemetry.startUISession(TelemetryContract.Session.FRECENCY); 1.233 + } 1.234 + 1.235 + @Override 1.236 + public void onPause() { 1.237 + super.onPause(); 1.238 + 1.239 + Telemetry.stopUISession(TelemetryContract.Session.FRECENCY); 1.240 + } 1.241 + 1.242 + @Override 1.243 + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 1.244 + // All list views are styled to look the same with a global activity theme. 1.245 + // If the style of the list changes, inflate it from an XML. 1.246 + mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false); 1.247 + mList = (HomeListView) mView.findViewById(R.id.home_list_view); 1.248 + 1.249 + return mView; 1.250 + } 1.251 + 1.252 + @Override 1.253 + public void onDestroyView() { 1.254 + super.onDestroyView(); 1.255 + 1.256 + unregisterEventListener("SearchEngines:Data"); 1.257 + 1.258 + mList.setAdapter(null); 1.259 + mList = null; 1.260 + 1.261 + mView = null; 1.262 + mSuggestionsOptInPrompt = null; 1.263 + mSuggestClient = null; 1.264 + } 1.265 + 1.266 + @Override 1.267 + public void onViewCreated(View view, Bundle savedInstanceState) { 1.268 + super.onViewCreated(view, savedInstanceState); 1.269 + mList.setTag(HomePager.LIST_TAG_BROWSER_SEARCH); 1.270 + 1.271 + mList.setOnItemClickListener(new AdapterView.OnItemClickListener() { 1.272 + @Override 1.273 + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 1.274 + // Perform the user-entered search if the user clicks on a search engine row. 1.275 + // This row will be disabled if suggestions (in addition to the user-entered term) are showing. 1.276 + if (view instanceof SearchEngineRow) { 1.277 + ((SearchEngineRow) view).performUserEnteredSearch(); 1.278 + return; 1.279 + } 1.280 + 1.281 + // Account for the search engine rows. 1.282 + position -= getSuggestEngineCount(); 1.283 + final Cursor c = mAdapter.getCursor(position); 1.284 + final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL)); 1.285 + 1.286 + // The "urlbar" and "frecency" sessions can be open at the same time. Use the LIST_ITEM 1.287 + // method to set this LOAD_URL event apart from the case where the user commits what's in 1.288 + // the url bar. 1.289 + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM); 1.290 + 1.291 + // This item is a TwoLinePageRow, so we allow switch-to-tab. 1.292 + mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); 1.293 + } 1.294 + }); 1.295 + 1.296 + mList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { 1.297 + @Override 1.298 + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { 1.299 + // Don't do anything when the user long-clicks on a search engine row. 1.300 + if (view instanceof SearchEngineRow) { 1.301 + return true; 1.302 + } 1.303 + 1.304 + // Account for the search engine rows. 1.305 + position -= getSuggestEngineCount(); 1.306 + return mList.onItemLongClick(parent, view, position, id); 1.307 + } 1.308 + }); 1.309 + 1.310 + final ListSelectionListener listener = new ListSelectionListener(); 1.311 + mList.setOnItemSelectedListener(listener); 1.312 + mList.setOnFocusChangeListener(listener); 1.313 + 1.314 + mList.setOnKeyListener(new View.OnKeyListener() { 1.315 + @Override 1.316 + public boolean onKey(View v, int keyCode, android.view.KeyEvent event) { 1.317 + final View selected = mList.getSelectedView(); 1.318 + 1.319 + if (selected instanceof SearchEngineRow) { 1.320 + return selected.onKeyDown(keyCode, event); 1.321 + } 1.322 + return false; 1.323 + } 1.324 + }); 1.325 + 1.326 + registerForContextMenu(mList); 1.327 + registerEventListener("SearchEngines:Data"); 1.328 + 1.329 + GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("SearchEngines:GetVisible", null)); 1.330 + } 1.331 + 1.332 + @Override 1.333 + public void onActivityCreated(Bundle savedInstanceState) { 1.334 + super.onActivityCreated(savedInstanceState); 1.335 + 1.336 + // Intialize the search adapter 1.337 + mAdapter = new SearchAdapter(getActivity()); 1.338 + mList.setAdapter(mAdapter); 1.339 + 1.340 + // Only create an instance when we need it 1.341 + mSuggestionLoaderCallbacks = null; 1.342 + 1.343 + // Create callbacks before the initial loader is started 1.344 + mCursorLoaderCallbacks = new CursorLoaderCallbacks(); 1.345 + loadIfVisible(); 1.346 + } 1.347 + 1.348 + @Override 1.349 + public void handleMessage(String event, final JSONObject message) { 1.350 + if (event.equals("SearchEngines:Data")) { 1.351 + ThreadUtils.postToUiThread(new Runnable() { 1.352 + @Override 1.353 + public void run() { 1.354 + setSearchEngines(message); 1.355 + } 1.356 + }); 1.357 + } 1.358 + } 1.359 + 1.360 + @Override 1.361 + protected void load() { 1.362 + SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); 1.363 + } 1.364 + 1.365 + private void handleAutocomplete(String searchTerm, Cursor c) { 1.366 + if (c == null || 1.367 + mAutocompleteHandler == null || 1.368 + TextUtils.isEmpty(searchTerm)) { 1.369 + return; 1.370 + } 1.371 + 1.372 + // Avoid searching the path if we don't have to. Currently just 1.373 + // decided by whether there is a '/' character in the string. 1.374 + final boolean searchPath = searchTerm.indexOf('/') > 0; 1.375 + final String autocompletion = findAutocompletion(searchTerm, c, searchPath); 1.376 + 1.377 + if (autocompletion == null || mAutocompleteHandler == null) { 1.378 + return; 1.379 + } 1.380 + 1.381 + // Prefetch auto-completed domain since it's a likely target 1.382 + GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Session:Prefetch", "http://" + autocompletion)); 1.383 + 1.384 + mAutocompleteHandler.onAutocomplete(autocompletion); 1.385 + mAutocompleteHandler = null; 1.386 + } 1.387 + 1.388 + /** 1.389 + * Returns the substring of a provided URI, starting at the given offset, 1.390 + * and extending up to the end of the path segment in which the provided 1.391 + * index is found. 1.392 + * 1.393 + * For example, given 1.394 + * 1.395 + * "www.reddit.com/r/boop/abcdef", 0, ? 1.396 + * 1.397 + * this method returns 1.398 + * 1.399 + * ?=2: "www.reddit.com/" 1.400 + * ?=17: "www.reddit.com/r/boop/" 1.401 + * ?=21: "www.reddit.com/r/boop/" 1.402 + * ?=22: "www.reddit.com/r/boop/abcdef" 1.403 + * 1.404 + */ 1.405 + private static String uriSubstringUpToMatchedPath(final String url, final int offset, final int begin) { 1.406 + final int afterEnd = url.length(); 1.407 + 1.408 + // We want to include the trailing slash, but not other characters. 1.409 + int chop = url.indexOf('/', begin); 1.410 + if (chop != -1) { 1.411 + ++chop; 1.412 + if (chop < offset) { 1.413 + // This isn't supposed to happen. Fall back to returning the whole damn thing. 1.414 + return url; 1.415 + } 1.416 + } else { 1.417 + chop = url.indexOf('?', begin); 1.418 + if (chop == -1) { 1.419 + chop = url.indexOf('#', begin); 1.420 + } 1.421 + if (chop == -1) { 1.422 + chop = afterEnd; 1.423 + } 1.424 + } 1.425 + 1.426 + return url.substring(offset, chop); 1.427 + } 1.428 + 1.429 + private String findAutocompletion(String searchTerm, Cursor c, boolean searchPath) { 1.430 + if (!c.moveToFirst()) { 1.431 + return null; 1.432 + } 1.433 + 1.434 + final int searchLength = searchTerm.length(); 1.435 + final int urlIndex = c.getColumnIndexOrThrow(URLColumns.URL); 1.436 + int searchCount = 0; 1.437 + 1.438 + do { 1.439 + final String url = c.getString(urlIndex); 1.440 + 1.441 + if (searchCount == 0) { 1.442 + // Prefetch the first item in the list since it's weighted the highest 1.443 + GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Session:Prefetch", url.toString())); 1.444 + } 1.445 + 1.446 + // Does the completion match against the whole URL? This will match 1.447 + // about: pages, as well as user input including "http://...". 1.448 + if (url.startsWith(searchTerm)) { 1.449 + return uriSubstringUpToMatchedPath(url, 0, 1.450 + (searchLength > HTTPS_PREFIX_LENGTH) ? searchLength : HTTPS_PREFIX_LENGTH); 1.451 + } 1.452 + 1.453 + final Uri uri = Uri.parse(url); 1.454 + final String host = uri.getHost(); 1.455 + 1.456 + // Host may be null for about pages. 1.457 + if (host == null) { 1.458 + continue; 1.459 + } 1.460 + 1.461 + if (host.startsWith(searchTerm)) { 1.462 + return host + "/"; 1.463 + } 1.464 + 1.465 + final String strippedHost = StringUtils.stripCommonSubdomains(host); 1.466 + if (strippedHost.startsWith(searchTerm)) { 1.467 + return strippedHost + "/"; 1.468 + } 1.469 + 1.470 + ++searchCount; 1.471 + 1.472 + if (!searchPath) { 1.473 + continue; 1.474 + } 1.475 + 1.476 + // Otherwise, if we're matching paths, let's compare against the string itself. 1.477 + final int hostOffset = url.indexOf(strippedHost); 1.478 + if (hostOffset == -1) { 1.479 + // This was a URL string that parsed to a different host (normalized?). 1.480 + // Give up. 1.481 + continue; 1.482 + } 1.483 + 1.484 + // We already matched the non-stripped host, so now we're 1.485 + // substring-searching in the part of the URL without the common 1.486 + // subdomains. 1.487 + if (url.startsWith(searchTerm, hostOffset)) { 1.488 + // Great! Return including the rest of the path segment. 1.489 + return uriSubstringUpToMatchedPath(url, hostOffset, hostOffset + searchLength); 1.490 + } 1.491 + } while (searchCount < MAX_AUTOCOMPLETE_SEARCH && c.moveToNext()); 1.492 + 1.493 + return null; 1.494 + } 1.495 + 1.496 + private void filterSuggestions() { 1.497 + if (mSuggestClient == null || !mSuggestionsEnabled) { 1.498 + return; 1.499 + } 1.500 + 1.501 + if (mSuggestionLoaderCallbacks == null) { 1.502 + mSuggestionLoaderCallbacks = new SuggestionLoaderCallbacks(); 1.503 + } 1.504 + 1.505 + getLoaderManager().restartLoader(LOADER_ID_SUGGESTION, null, mSuggestionLoaderCallbacks); 1.506 + } 1.507 + 1.508 + private void setSuggestions(ArrayList<String> suggestions) { 1.509 + mSearchEngines.get(0).setSuggestions(suggestions); 1.510 + mAdapter.notifyDataSetChanged(); 1.511 + } 1.512 + 1.513 + private void setSearchEngines(JSONObject data) { 1.514 + // This method is called via a Runnable posted from the Gecko thread, so 1.515 + // it's possible the fragment and/or its view has been destroyed by the 1.516 + // time we get here. If so, just abort. 1.517 + if (mView == null) { 1.518 + return; 1.519 + } 1.520 + 1.521 + try { 1.522 + final JSONObject suggest = data.getJSONObject("suggest"); 1.523 + final String suggestEngine = suggest.optString("engine", null); 1.524 + final String suggestTemplate = suggest.optString("template", null); 1.525 + final boolean suggestionsPrompted = suggest.getBoolean("prompted"); 1.526 + final JSONArray engines = data.getJSONArray("searchEngines"); 1.527 + 1.528 + mSuggestionsEnabled = suggest.getBoolean("enabled"); 1.529 + 1.530 + ArrayList<SearchEngine> searchEngines = new ArrayList<SearchEngine>(); 1.531 + for (int i = 0; i < engines.length(); i++) { 1.532 + final JSONObject engineJSON = engines.getJSONObject(i); 1.533 + final SearchEngine engine = new SearchEngine(engineJSON); 1.534 + 1.535 + if (engine.name.equals(suggestEngine) && suggestTemplate != null) { 1.536 + // Suggest engine should be at the front of the list. 1.537 + searchEngines.add(0, engine); 1.538 + 1.539 + // The only time Tabs.getInstance().getSelectedTab() should 1.540 + // be null is when we're restoring after a crash. We should 1.541 + // never restore private tabs when that happens, so it 1.542 + // should be safe to assume that null means non-private. 1.543 + Tab tab = Tabs.getInstance().getSelectedTab(); 1.544 + final boolean isPrivate = (tab != null && tab.isPrivate()); 1.545 + 1.546 + // Only create a new instance of SuggestClient if it hasn't been 1.547 + // set yet. e.g. Robocop tests might set it directly before search 1.548 + // engines are loaded. 1.549 + if (mSuggestClient == null && !isPrivate) { 1.550 + setSuggestClient(new SuggestClient(getActivity(), suggestTemplate, 1.551 + SUGGESTION_TIMEOUT, SUGGESTION_MAX)); 1.552 + } 1.553 + } else { 1.554 + searchEngines.add(engine); 1.555 + } 1.556 + } 1.557 + 1.558 + mSearchEngines = searchEngines; 1.559 + 1.560 + if (mAdapter != null) { 1.561 + mAdapter.notifyDataSetChanged(); 1.562 + } 1.563 + 1.564 + // Show suggestions opt-in prompt only if suggestions are not enabled yet, 1.565 + // user hasn't been prompted and we're not on a private browsing tab. 1.566 + if (!mSuggestionsEnabled && !suggestionsPrompted && mSuggestClient != null) { 1.567 + showSuggestionsOptIn(); 1.568 + } 1.569 + } catch (JSONException e) { 1.570 + Log.e(LOGTAG, "Error getting search engine JSON", e); 1.571 + } 1.572 + 1.573 + filterSuggestions(); 1.574 + } 1.575 + 1.576 + /** 1.577 + * Sets the private SuggestClient instance. Should only be called if the suggestClient is 1.578 + * null (i.e. has not yet been initialized or has been nulled). Non-private access is 1.579 + * for testing purposes only. 1.580 + */ 1.581 + @RobocopTarget 1.582 + public void setSuggestClient(final SuggestClient client) { 1.583 + if (mSuggestClient != null) { 1.584 + throw new IllegalStateException("Can only set the SuggestClient if it has not " + 1.585 + "yet been initialized!"); 1.586 + } 1.587 + mSuggestClient = client; 1.588 + } 1.589 + 1.590 + private void showSuggestionsOptIn() { 1.591 + // Return if the ViewStub was already inflated - an inflated ViewStub is removed from the 1.592 + // View hierarchy so a second call to findViewById will return null. 1.593 + if (mSuggestionsOptInPrompt != null) { 1.594 + return; 1.595 + } 1.596 + 1.597 + mSuggestionsOptInPrompt = ((ViewStub) mView.findViewById(R.id.suggestions_opt_in_prompt)).inflate(); 1.598 + 1.599 + TextView promptText = (TextView) mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_title); 1.600 + promptText.setText(getResources().getString(R.string.suggestions_prompt)); 1.601 + 1.602 + final View yesButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_yes); 1.603 + final View noButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_no); 1.604 + 1.605 + final OnClickListener listener = new OnClickListener() { 1.606 + @Override 1.607 + public void onClick(View v) { 1.608 + // Prevent the buttons from being clicked multiple times (bug 816902) 1.609 + yesButton.setOnClickListener(null); 1.610 + noButton.setOnClickListener(null); 1.611 + 1.612 + setSuggestionsEnabled(v == yesButton); 1.613 + } 1.614 + }; 1.615 + 1.616 + yesButton.setOnClickListener(listener); 1.617 + noButton.setOnClickListener(listener); 1.618 + 1.619 + // If the prompt gains focus, automatically pass focus to the 1.620 + // yes button in the prompt. 1.621 + final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt); 1.622 + prompt.setOnFocusChangeListener(new View.OnFocusChangeListener() { 1.623 + @Override 1.624 + public void onFocusChange(View v, boolean hasFocus) { 1.625 + if (hasFocus) { 1.626 + yesButton.requestFocus(); 1.627 + } 1.628 + } 1.629 + }); 1.630 + } 1.631 + 1.632 + private void setSuggestionsEnabled(final boolean enabled) { 1.633 + // Clicking the yes/no buttons quickly can cause the click events be 1.634 + // queued before the listeners are removed above, so it's possible 1.635 + // setSuggestionsEnabled() can be called twice. mSuggestionsOptInPrompt 1.636 + // can be null if this happens (bug 828480). 1.637 + if (mSuggestionsOptInPrompt == null) { 1.638 + return; 1.639 + } 1.640 + 1.641 + // Make suggestions appear immediately after the user opts in 1.642 + ThreadUtils.postToBackgroundThread(new Runnable() { 1.643 + @Override 1.644 + public void run() { 1.645 + SuggestClient client = mSuggestClient; 1.646 + if (client != null) { 1.647 + client.query(mSearchTerm); 1.648 + } 1.649 + } 1.650 + }); 1.651 + 1.652 + // Pref observer in gecko will also set prompted = true 1.653 + PrefsHelper.setPref("browser.search.suggest.enabled", enabled); 1.654 + 1.655 + TranslateAnimation slideAnimation = new TranslateAnimation(0, mSuggestionsOptInPrompt.getWidth(), 0, 0); 1.656 + slideAnimation.setDuration(ANIMATION_DURATION); 1.657 + slideAnimation.setInterpolator(new AccelerateInterpolator()); 1.658 + slideAnimation.setFillAfter(true); 1.659 + final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt); 1.660 + 1.661 + TranslateAnimation shrinkAnimation = new TranslateAnimation(0, 0, 0, -1 * mSuggestionsOptInPrompt.getHeight()); 1.662 + shrinkAnimation.setDuration(ANIMATION_DURATION); 1.663 + shrinkAnimation.setFillAfter(true); 1.664 + shrinkAnimation.setStartOffset(slideAnimation.getDuration()); 1.665 + shrinkAnimation.setAnimationListener(new Animation.AnimationListener() { 1.666 + @Override 1.667 + public void onAnimationStart(Animation a) { 1.668 + // Increase the height of the view so a gap isn't shown during animation 1.669 + mView.getLayoutParams().height = mView.getHeight() + 1.670 + mSuggestionsOptInPrompt.getHeight(); 1.671 + mView.requestLayout(); 1.672 + } 1.673 + 1.674 + @Override 1.675 + public void onAnimationRepeat(Animation a) {} 1.676 + 1.677 + @Override 1.678 + public void onAnimationEnd(Animation a) { 1.679 + // Removing the view immediately results in a NPE in 1.680 + // dispatchDraw(), possibly because this callback executes 1.681 + // before drawing is finished. Posting this as a Runnable fixes 1.682 + // the issue. 1.683 + mView.post(new Runnable() { 1.684 + @Override 1.685 + public void run() { 1.686 + mView.removeView(mSuggestionsOptInPrompt); 1.687 + mList.clearAnimation(); 1.688 + mSuggestionsOptInPrompt = null; 1.689 + 1.690 + if (enabled) { 1.691 + // Reset the view height 1.692 + mView.getLayoutParams().height = LayoutParams.MATCH_PARENT; 1.693 + 1.694 + mSuggestionsEnabled = enabled; 1.695 + mAnimateSuggestions = true; 1.696 + mAdapter.notifyDataSetChanged(); 1.697 + filterSuggestions(); 1.698 + } 1.699 + } 1.700 + }); 1.701 + } 1.702 + }); 1.703 + 1.704 + prompt.startAnimation(slideAnimation); 1.705 + mSuggestionsOptInPrompt.startAnimation(shrinkAnimation); 1.706 + mList.startAnimation(shrinkAnimation); 1.707 + } 1.708 + 1.709 + private int getSuggestEngineCount() { 1.710 + return (TextUtils.isEmpty(mSearchTerm) || mSuggestClient == null || !mSuggestionsEnabled) ? 0 : 1; 1.711 + } 1.712 + 1.713 + private void registerEventListener(String eventName) { 1.714 + GeckoAppShell.registerEventListener(eventName, this); 1.715 + } 1.716 + 1.717 + private void unregisterEventListener(String eventName) { 1.718 + GeckoAppShell.unregisterEventListener(eventName, this); 1.719 + } 1.720 + 1.721 + private void restartSearchLoader() { 1.722 + SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); 1.723 + } 1.724 + 1.725 + private void initSearchLoader() { 1.726 + SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); 1.727 + } 1.728 + 1.729 + public void filter(String searchTerm, AutocompleteHandler handler) { 1.730 + if (TextUtils.isEmpty(searchTerm)) { 1.731 + return; 1.732 + } 1.733 + 1.734 + final boolean isNewFilter = !TextUtils.equals(mSearchTerm, searchTerm); 1.735 + 1.736 + mSearchTerm = searchTerm; 1.737 + mAutocompleteHandler = handler; 1.738 + 1.739 + if (isVisible()) { 1.740 + if (isNewFilter) { 1.741 + // The adapter depends on the search term to determine its number 1.742 + // of items. Make it we notify the view about it. 1.743 + mAdapter.notifyDataSetChanged(); 1.744 + 1.745 + // Restart loaders with the new search term 1.746 + restartSearchLoader(); 1.747 + filterSuggestions(); 1.748 + } else { 1.749 + // The search term hasn't changed, simply reuse any existing 1.750 + // loader for the current search term. This will ensure autocompletion 1.751 + // is consistently triggered (see bug 933739). 1.752 + initSearchLoader(); 1.753 + } 1.754 + } 1.755 + } 1.756 + 1.757 + private static class SuggestionAsyncLoader extends AsyncTaskLoader<ArrayList<String>> { 1.758 + private final SuggestClient mSuggestClient; 1.759 + private final String mSearchTerm; 1.760 + private ArrayList<String> mSuggestions; 1.761 + 1.762 + public SuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) { 1.763 + super(context); 1.764 + mSuggestClient = suggestClient; 1.765 + mSearchTerm = searchTerm; 1.766 + mSuggestions = null; 1.767 + } 1.768 + 1.769 + @Override 1.770 + public ArrayList<String> loadInBackground() { 1.771 + return mSuggestClient.query(mSearchTerm); 1.772 + } 1.773 + 1.774 + @Override 1.775 + public void deliverResult(ArrayList<String> suggestions) { 1.776 + mSuggestions = suggestions; 1.777 + 1.778 + if (isStarted()) { 1.779 + super.deliverResult(mSuggestions); 1.780 + } 1.781 + } 1.782 + 1.783 + @Override 1.784 + protected void onStartLoading() { 1.785 + if (mSuggestions != null) { 1.786 + deliverResult(mSuggestions); 1.787 + } 1.788 + 1.789 + if (takeContentChanged() || mSuggestions == null) { 1.790 + forceLoad(); 1.791 + } 1.792 + } 1.793 + 1.794 + @Override 1.795 + protected void onStopLoading() { 1.796 + cancelLoad(); 1.797 + } 1.798 + 1.799 + @Override 1.800 + protected void onReset() { 1.801 + super.onReset(); 1.802 + 1.803 + onStopLoading(); 1.804 + mSuggestions = null; 1.805 + } 1.806 + } 1.807 + 1.808 + private class SearchAdapter extends MultiTypeCursorAdapter { 1.809 + private static final int ROW_SEARCH = 0; 1.810 + private static final int ROW_STANDARD = 1; 1.811 + private static final int ROW_SUGGEST = 2; 1.812 + 1.813 + public SearchAdapter(Context context) { 1.814 + super(context, null, new int[] { ROW_STANDARD, 1.815 + ROW_SEARCH, 1.816 + ROW_SUGGEST }, 1.817 + new int[] { R.layout.home_item_row, 1.818 + R.layout.home_search_item_row, 1.819 + R.layout.home_search_item_row }); 1.820 + } 1.821 + 1.822 + @Override 1.823 + public int getItemViewType(int position) { 1.824 + final int engine = getEngineIndex(position); 1.825 + 1.826 + if (engine == -1) { 1.827 + return ROW_STANDARD; 1.828 + } else if (engine == 0 && mSuggestionsEnabled) { 1.829 + // Give suggestion views their own type to prevent them from 1.830 + // sharing other recycled search engine views. Using other 1.831 + // recycled views for the suggestion row can break animations 1.832 + // (bug 815937). 1.833 + return ROW_SUGGEST; 1.834 + } 1.835 + 1.836 + return ROW_SEARCH; 1.837 + } 1.838 + 1.839 + @Override 1.840 + public boolean isEnabled(int position) { 1.841 + // If we're using a gamepad or keyboard, allow the row to be 1.842 + // focused so it can pass the focus to its child suggestion views. 1.843 + if (!mList.isInTouchMode()) { 1.844 + return true; 1.845 + } 1.846 + 1.847 + // If the suggestion row only contains one item (the user-entered 1.848 + // query), allow the entire row to be clickable; clicking the row 1.849 + // has the same effect as clicking the single suggestion. If the 1.850 + // row contains multiple items, clicking the row will do nothing. 1.851 + final int index = getEngineIndex(position); 1.852 + if (index != -1) { 1.853 + return !mSearchEngines.get(index).hasSuggestions(); 1.854 + } 1.855 + 1.856 + return true; 1.857 + } 1.858 + 1.859 + // Add the search engines to the number of reported results. 1.860 + @Override 1.861 + public int getCount() { 1.862 + final int resultCount = super.getCount(); 1.863 + 1.864 + // Don't show search engines or suggestions if search field is empty 1.865 + if (TextUtils.isEmpty(mSearchTerm)) { 1.866 + return resultCount; 1.867 + } 1.868 + 1.869 + return resultCount + mSearchEngines.size(); 1.870 + } 1.871 + 1.872 + @Override 1.873 + public void bindView(View view, Context context, int position) { 1.874 + final int type = getItemViewType(position); 1.875 + 1.876 + if (type == ROW_SEARCH || type == ROW_SUGGEST) { 1.877 + final SearchEngineRow row = (SearchEngineRow) view; 1.878 + row.setOnUrlOpenListener(mUrlOpenListener); 1.879 + row.setOnSearchListener(mSearchListener); 1.880 + row.setOnEditSuggestionListener(mEditSuggestionListener); 1.881 + row.setSearchTerm(mSearchTerm); 1.882 + 1.883 + final SearchEngine engine = mSearchEngines.get(getEngineIndex(position)); 1.884 + final boolean animate = (mAnimateSuggestions && engine.hasSuggestions()); 1.885 + row.updateFromSearchEngine(engine, animate); 1.886 + if (animate) { 1.887 + // Only animate suggestions the first time they are shown 1.888 + mAnimateSuggestions = false; 1.889 + } 1.890 + } else { 1.891 + // Account for the search engines 1.892 + position -= getSuggestEngineCount(); 1.893 + 1.894 + final Cursor c = getCursor(position); 1.895 + final TwoLinePageRow row = (TwoLinePageRow) view; 1.896 + row.updateFromCursor(c); 1.897 + } 1.898 + } 1.899 + 1.900 + private int getEngineIndex(int position) { 1.901 + final int resultCount = super.getCount(); 1.902 + final int suggestEngineCount = getSuggestEngineCount(); 1.903 + 1.904 + // Return suggest engine index 1.905 + if (position < suggestEngineCount) { 1.906 + return position; 1.907 + } 1.908 + 1.909 + // Not an engine 1.910 + if (position - suggestEngineCount < resultCount) { 1.911 + return -1; 1.912 + } 1.913 + 1.914 + // Return search engine index 1.915 + return position - resultCount; 1.916 + } 1.917 + } 1.918 + 1.919 + private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> { 1.920 + @Override 1.921 + public Loader<Cursor> onCreateLoader(int id, Bundle args) { 1.922 + return SearchLoader.createInstance(getActivity(), args); 1.923 + } 1.924 + 1.925 + @Override 1.926 + public void onLoadFinished(Loader<Cursor> loader, Cursor c) { 1.927 + mAdapter.swapCursor(c); 1.928 + 1.929 + // We should handle autocompletion based on the search term 1.930 + // associated with the loader that has just provided 1.931 + // the results. 1.932 + SearchCursorLoader searchLoader = (SearchCursorLoader) loader; 1.933 + handleAutocomplete(searchLoader.getSearchTerm(), c); 1.934 + } 1.935 + 1.936 + @Override 1.937 + public void onLoaderReset(Loader<Cursor> loader) { 1.938 + mAdapter.swapCursor(null); 1.939 + } 1.940 + } 1.941 + 1.942 + private class SuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> { 1.943 + @Override 1.944 + public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) { 1.945 + // mSuggestClient is set to null in onDestroyView(), so using it 1.946 + // safely here relies on the fact that onCreateLoader() is called 1.947 + // synchronously in restartLoader(). 1.948 + return new SuggestionAsyncLoader(getActivity(), mSuggestClient, mSearchTerm); 1.949 + } 1.950 + 1.951 + @Override 1.952 + public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) { 1.953 + setSuggestions(suggestions); 1.954 + } 1.955 + 1.956 + @Override 1.957 + public void onLoaderReset(Loader<ArrayList<String>> loader) { 1.958 + setSuggestions(new ArrayList<String>()); 1.959 + } 1.960 + } 1.961 + 1.962 + private static class ListSelectionListener implements View.OnFocusChangeListener, 1.963 + AdapterView.OnItemSelectedListener { 1.964 + private SearchEngineRow mSelectedEngineRow; 1.965 + 1.966 + @Override 1.967 + public void onFocusChange(View v, boolean hasFocus) { 1.968 + if (hasFocus) { 1.969 + View selectedRow = ((ListView) v).getSelectedView(); 1.970 + if (selectedRow != null) { 1.971 + selectRow(selectedRow); 1.972 + } 1.973 + } else { 1.974 + deselectRow(); 1.975 + } 1.976 + } 1.977 + 1.978 + @Override 1.979 + public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { 1.980 + deselectRow(); 1.981 + selectRow(view); 1.982 + } 1.983 + 1.984 + @Override 1.985 + public void onNothingSelected(AdapterView<?> parent) { 1.986 + deselectRow(); 1.987 + } 1.988 + 1.989 + private void selectRow(View row) { 1.990 + if (row instanceof SearchEngineRow) { 1.991 + mSelectedEngineRow = (SearchEngineRow) row; 1.992 + mSelectedEngineRow.onSelected(); 1.993 + } 1.994 + } 1.995 + 1.996 + private void deselectRow() { 1.997 + if (mSelectedEngineRow != null) { 1.998 + mSelectedEngineRow.onDeselected(); 1.999 + mSelectedEngineRow = null; 1.1000 + } 1.1001 + } 1.1002 + } 1.1003 + 1.1004 + /** 1.1005 + * HomeSearchListView is a list view for displaying search engine results on the awesome screen. 1.1006 + */ 1.1007 + public static class HomeSearchListView extends HomeListView { 1.1008 + public HomeSearchListView(Context context, AttributeSet attrs) { 1.1009 + this(context, attrs, R.attr.homeListViewStyle); 1.1010 + } 1.1011 + 1.1012 + public HomeSearchListView(Context context, AttributeSet attrs, int defStyle) { 1.1013 + super(context, attrs, defStyle); 1.1014 + } 1.1015 + 1.1016 + @Override 1.1017 + public boolean onTouchEvent(MotionEvent event) { 1.1018 + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { 1.1019 + // Dismiss the soft keyboard. 1.1020 + requestFocus(); 1.1021 + } 1.1022 + 1.1023 + return super.onTouchEvent(event); 1.1024 + } 1.1025 + } 1.1026 +}