package org.wikipedia.search; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.util.LruCache; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.ListView; import android.widget.TextView; import com.facebook.drawee.view.SimpleDraweeView; import org.apache.commons.lang3.StringUtils; import org.wikipedia.LongPressHandler; import org.wikipedia.R; import org.wikipedia.WikipediaApp; import org.wikipedia.activity.FragmentUtil; import org.wikipedia.analytics.SearchFunnel; import org.wikipedia.history.HistoryEntry; import org.wikipedia.page.PageTitle; import org.wikipedia.readinglist.AddToReadingListDialog; import org.wikipedia.util.StringUtil; import org.wikipedia.views.GoneIfEmptyTextView; import org.wikipedia.views.ViewUtil; import org.wikipedia.views.WikiErrorView; import java.text.Collator; import java.util.ArrayList; import java.util.List; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; import butterknife.OnItemClick; import butterknife.Unbinder; import static org.apache.commons.lang3.StringUtils.isBlank; public class SearchResultsFragment extends Fragment { public interface Callback { void onSearchResultCopyLink(@NonNull PageTitle title); void onSearchResultAddToList(@NonNull PageTitle title, @NonNull AddToReadingListDialog.InvokeSource source); void onSearchResultShareLink(@NonNull PageTitle title); void onSearchProgressBar(boolean enabled); void navigateToTitle(@NonNull PageTitle item, boolean inNewTab, int position); void setSearchText(@NonNull CharSequence text); @NonNull SearchFunnel getFunnel(); } private static final int BATCH_SIZE = 20; private static final int DELAY_MILLIS = 300; private static final int MESSAGE_SEARCH = 1; private static final int MAX_CACHE_SIZE_SEARCH_RESULTS = 4; /** * Constant to ease in the conversion of timestamps from nanoseconds to milliseconds. */ private static final int NANO_TO_MILLI = 1_000_000; @BindView(R.id.search_results_display) View searchResultsDisplay; @BindView(R.id.search_results_container) View searchResultsContainer; @BindView(R.id.search_results_list) ListView searchResultsList; @BindView(R.id.search_error_view) WikiErrorView searchErrorView; @BindView(R.id.search_empty_view) View searchEmptyView; @BindView(R.id.search_suggestion) TextView searchSuggestion; private Unbinder unbinder; private WikipediaApp app; @NonNull private final LruCache<String, List<SearchResult>> searchResultsCache = new LruCache<>(MAX_CACHE_SIZE_SEARCH_RESULTS); private Handler searchHandler; private TitleSearchTask curSearchTask; private String currentSearchTerm = ""; @Nullable private SearchResults lastFullTextResults; @NonNull private final List<SearchResult> totalResults = new ArrayList<>(); @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); app = WikipediaApp.getInstance(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.fragment_search_results, container, false); unbinder = ButterKnife.bind(this, view); SearchResultAdapter adapter = new SearchResultAdapter(inflater); searchResultsList.setAdapter(adapter); searchErrorView.setRetryClickListener(new View.OnClickListener() { @Override public void onClick(View view) { searchErrorView.setVisibility(View.GONE); startSearch(currentSearchTerm, true); } }); searchHandler = new Handler(new SearchHandlerCallback()); return view; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); new LongPressHandler(searchResultsList, HistoryEntry.SOURCE_SEARCH, new SearchResultsFragmentLongPressHandler()); } @Override public void onDestroyView() { searchErrorView.setRetryClickListener(null); unbinder.unbind(); unbinder = null; super.onDestroyView(); } @OnItemClick(R.id.search_results_list) void onItemClick(ListView view, int position) { Callback callback = callback(); if (callback != null) { PageTitle item = ((SearchResult) getAdapter().getItem(position)).getPageTitle(); callback.navigateToTitle(item, false, position); } } @OnClick(R.id.search_suggestion) void onSuggestionClick(View view) { Callback callback = callback(); String suggestion = (String) searchSuggestion.getTag(); if (callback != null && suggestion != null) { callback.getFunnel().searchDidYouMean(); callback.setSearchText(suggestion); startSearch(suggestion, true); } } public void show() { searchResultsDisplay.setVisibility(View.VISIBLE); } public void hide() { searchResultsDisplay.setVisibility(View.GONE); } public boolean isShowing() { return searchResultsDisplay.getVisibility() == View.VISIBLE; } /** * Kick off a search, based on a given search term. * @param term Phrase to search for. * @param force Whether to "force" starting this search. If the search is not forced, the * search may be delayed by a small time, so that network requests are not sent * too often. If the search is forced, the network request is sent immediately. */ public void startSearch(@Nullable String term, boolean force) { if (!force && currentSearchTerm.equals(term)) { return; } cancelSearchTask(); currentSearchTerm = term; if (isBlank(term)) { clearResults(); return; } List<SearchResult> cacheResult = searchResultsCache.get(app.getAppOrSystemLanguageCode() + "-" + term); if (cacheResult != null && !cacheResult.isEmpty()) { clearResults(); displayResults(cacheResult); return; } Message searchMessage = Message.obtain(); searchMessage.what = MESSAGE_SEARCH; searchMessage.obj = term; if (force) { searchHandler.sendMessage(searchMessage); } else { searchHandler.sendMessageDelayed(searchMessage, DELAY_MILLIS); } } private class SearchHandlerCallback implements Handler.Callback { @Override public boolean handleMessage(Message msg) { if (!isAdded()) { return true; } final String mySearchTerm = (String) msg.obj; doTitlePrefixSearch(mySearchTerm); return true; } } private void doTitlePrefixSearch(final String searchTerm) { // Use nanoTime to measure the time the search was started. final long startTime = System.nanoTime(); TitleSearchTask searchTask = new TitleSearchTask(app.getAPIForSite(app.getWikiSite()), app.getWikiSite(), searchTerm) { @Override public void onBeforeExecute() { updateProgressBar(true); } @Override public void onFinish(SearchResults results) { if (!isAdded()) { return; } Callback callback = callback(); List<SearchResult> resultList = results.getResults(); // To ease data analysis and better make the funnel track with user behaviour, // only transmit search results events if there are a nonzero number of results if (!resultList.isEmpty() && callback != null) { // Calculate total time taken to display results, in milliseconds final int timeToDisplay = (int) ((System.nanoTime() - startTime) / NANO_TO_MILLI); callback.getFunnel().searchResults(false, resultList.size(), timeToDisplay); } updateProgressBar(false); searchErrorView.setVisibility(View.GONE); if (!resultList.isEmpty()) { clearResults(); displayResults(resultList); } // add titles to cache... searchResultsCache.put(app.getAppOrSystemLanguageCode() + "-" + searchTerm, resultList); curSearchTask = null; final String suggestion = results.getSuggestion(); if (!suggestion.isEmpty()) { searchSuggestion.setText(StringUtil.fromHtml("<u>" + String.format(getString(R.string.search_did_you_mean), suggestion) + "</u>")); searchSuggestion.setTag(suggestion); searchSuggestion.setVisibility(View.VISIBLE); } else { searchSuggestion.setVisibility(View.GONE); } // scroll to top, but post it to the message queue, because it should be done // after the data set is updated. searchResultsList.post(new Runnable() { @Override public void run() { if (!isAdded()) { return; } searchResultsList.setSelectionAfterHeaderView(); } }); if (resultList.isEmpty()) { // kick off full text search if we get no results doFullTextSearch(currentSearchTerm, null, true); } } @Override public void onCatch(Throwable caught) { if (!isAdded()) { return; } // Calculate total time taken to display results, in milliseconds final int timeToDisplay = (int) ((System.nanoTime() - startTime) / NANO_TO_MILLI); Callback callback = callback(); if (callback != null) { callback.getFunnel().searchError(false, timeToDisplay); } updateProgressBar(false); searchErrorView.setVisibility(View.VISIBLE); searchErrorView.setError(caught); searchResultsContainer.setVisibility(View.GONE); curSearchTask = null; } }; cancelSearchTask(); curSearchTask = searchTask; searchTask.execute(); } private void cancelSearchTask() { updateProgressBar(false); searchHandler.removeMessages(MESSAGE_SEARCH); if (curSearchTask != null) { // This does not cancel the HTTP request itself // But it does cancel the execution of onFinish // This makes sure that a slower previous search query does not override // the results of a newer search query curSearchTask.cancel(); } } private void doFullTextSearch(final String searchTerm, final SearchResults.ContinueOffset continueOffset, final boolean clearOnSuccess) { // Use nanoTime to measure the time the search was started. final long startTime = System.nanoTime(); new FullSearchArticlesTask(app.getAPIForSite(app.getWikiSite()), app.getWikiSite(), searchTerm, BATCH_SIZE, continueOffset, false) { @Override public void onBeforeExecute() { updateProgressBar(true); } @Override public void onFinish(SearchResults results) { if (!isAdded()) { return; } if (clearOnSuccess) { clearResults(false); } Callback callback = callback(); // To ease data analysis and better make the funnel track with user behaviour, // only transmit search results events if there are a nonzero number of results final List<SearchResult> resultList = results.getResults(); if (!resultList.isEmpty() && callback != null) { // Calculate total time taken to display results, in milliseconds final int timeToDisplay = (int) ((System.nanoTime() - startTime) / NANO_TO_MILLI); callback.getFunnel().searchResults(true, resultList.size(), timeToDisplay); } // append results to cache... List<SearchResult> cachedTitles = searchResultsCache.get(app.getAppOrSystemLanguageCode() + "-" + searchTerm); if (cachedTitles != null) { cachedTitles.addAll(resultList); } updateProgressBar(false); searchErrorView.setVisibility(View.GONE); // full text special: SearchResultsFragment.this.lastFullTextResults = results; displayResults(resultList); } @Override public void onCatch(Throwable caught) { if (!isAdded()) { return; } // Calculate total time taken to display results, in milliseconds final int timeToDisplay = (int) ((System.nanoTime() - startTime) / NANO_TO_MILLI); Callback callback = callback(); if (callback != null) { callback.getFunnel().searchError(true, timeToDisplay); } // if there's an error just log it and let the existing prefix search results be. updateProgressBar(false); } }.execute(); } @Nullable public PageTitle getFirstResult() { if (!totalResults.isEmpty()) { return totalResults.get(0).getPageTitle(); } else { return null; } } private void clearResults() { clearResults(true); } private void updateProgressBar(boolean enabled) { Callback callback = callback(); if (callback != null) { callback.onSearchProgressBar(enabled); } } private void clearResults(boolean clearSuggestion) { searchResultsContainer.setVisibility(View.GONE); searchEmptyView.setVisibility(View.GONE); searchErrorView.setVisibility(View.GONE); if (clearSuggestion) { searchSuggestion.setVisibility(View.GONE); } lastFullTextResults = null; totalResults.clear(); getAdapter().notifyDataSetChanged(); } private BaseAdapter getAdapter() { return (BaseAdapter) searchResultsList.getAdapter(); } /** * Displays results passed to it as search suggestions. * * @param results List of results to display. If null, clears the list of suggestions & hides it. */ private void displayResults(List<SearchResult> results) { for (SearchResult newResult : results) { boolean contains = false; for (SearchResult result : totalResults) { if (newResult.getPageTitle().equals(result.getPageTitle())) { contains = true; break; } } if (!contains) { totalResults.add(newResult); } } if (totalResults.isEmpty()) { searchEmptyView.setVisibility(View.VISIBLE); searchResultsContainer.setVisibility(View.GONE); } else { searchEmptyView.setVisibility(View.GONE); searchResultsContainer.setVisibility(View.VISIBLE); } getAdapter().notifyDataSetChanged(); } private class SearchResultsFragmentLongPressHandler implements org.wikipedia.LongPressHandler.ListViewContextMenuListener { private int lastPositionRequested; @Override public PageTitle getTitleForListPosition(int position) { lastPositionRequested = position; return ((SearchResult) getAdapter().getItem(position)).getPageTitle(); } @Override public void onOpenLink(PageTitle title, HistoryEntry entry) { Callback callback = callback(); if (callback != null) { callback.navigateToTitle(title, false, lastPositionRequested); } } @Override public void onOpenInNewTab(PageTitle title, HistoryEntry entry) { Callback callback = callback(); if (callback != null) { callback.navigateToTitle(title, true, lastPositionRequested); } } @Override public void onCopyLink(PageTitle title) { Callback callback = callback(); if (callback != null) { callback.onSearchResultCopyLink(title); } } @Override public void onShareLink(PageTitle title) { Callback callback = callback(); if (callback != null) { callback.onSearchResultShareLink(title); } } @Override public void onAddToList(@NonNull PageTitle title, @NonNull AddToReadingListDialog.InvokeSource source) { Callback callback = callback(); if (callback != null) { callback.onSearchResultAddToList(title, source); } } } private final class SearchResultAdapter extends BaseAdapter { private final LayoutInflater inflater; SearchResultAdapter(LayoutInflater inflater) { this.inflater = inflater; } @Override public int getCount() { return totalResults.size(); } @Override public Object getItem(int position) { return totalResults.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { if (convertView == null) { convertView = inflater.inflate(R.layout.item_search_result, parent, false); } TextView pageTitleText = (TextView) convertView.findViewById(R.id.page_list_item_title); SearchResult result = (SearchResult) getItem(position); GoneIfEmptyTextView descriptionText = (GoneIfEmptyTextView) convertView.findViewById(R.id.page_list_item_description); View redirectContainer = convertView.findViewById(R.id.page_list_item_redirect_container); if (TextUtils.isEmpty(result.getRedirectFrom())) { redirectContainer.setVisibility(View.GONE); descriptionText.setText(StringUtils.capitalize(result.getPageTitle().getDescription())); } else { redirectContainer.setVisibility(View.VISIBLE); descriptionText.setVisibility(View.GONE); TextView redirectText = (TextView) convertView.findViewById(R.id.page_list_item_redirect); redirectText.setText(String.format(getString(R.string.search_redirect_from), result.getRedirectFrom())); } // highlight search term within the text String displayText = result.getPageTitle().getDisplayText(); int startIndex = indexOf(displayText, currentSearchTerm); if (startIndex >= 0) { displayText = displayText.substring(0, startIndex) + "<strong>" + displayText.substring(startIndex, startIndex + currentSearchTerm.length()) + "</strong>" + displayText.substring(startIndex + currentSearchTerm.length(), displayText.length()); pageTitleText.setText(StringUtil.fromHtml(displayText)); } else { pageTitleText.setText(displayText); } ViewUtil.loadImageUrlInto((SimpleDraweeView) convertView.findViewById(R.id.page_list_item_image), result.getPageTitle().getThumbUrl()); // ...and lastly, if we've scrolled to the last item in the list, then // continue searching! if (position == (totalResults.size() - 1)) { if (lastFullTextResults == null) { // the first full text search doFullTextSearch(currentSearchTerm, null, false); } else if (lastFullTextResults.getContinueOffset() != null) { // subsequent full text searches doFullTextSearch(currentSearchTerm, lastFullTextResults.getContinueOffset(), false); } } return convertView; } // case insensitive indexOf, also more lenient with similar chars, like chars with accents private int indexOf(String original, String search) { Collator collator = Collator.getInstance(); collator.setStrength(Collator.PRIMARY); for (int i = 0; i <= original.length() - search.length(); i++) { if (collator.equals(search, original.substring(i, i + search.length()))) { return i; } } return -1; } } @Nullable private Callback callback() { return FragmentUtil.getCallback(this, Callback.class); } }