| /* |
| * Copyright (C) 2009 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.globalsearch; |
| |
| import android.content.ComponentName; |
| import android.util.Log; |
| |
| import java.util.ArrayList; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.LinkedHashMap; |
| import java.util.Iterator; |
| import java.util.HashMap; |
| import java.util.Collection; |
| |
| /** |
| * Source suggestion backer shows (that is, snapshots) the results in the following order: |
| * - go to website (if applicable) |
| * - shortcuts |
| * - results from promoted sources that reported in time |
| * - a "search the web for 'query'" entry |
| * - a "more" item that, when expanded is followed by |
| * - an entry for each promoted source that has more results than was displayed above |
| * - an entry for each promoted source that reported too late |
| * - an entry for each non-promoted source |
| * |
| * The "search the web" and "more" entries appear only after the promoted sources are given |
| * a chance to return their results (either they all return their results, or the timeout elapses). |
| * |
| * Some set of sources are deemed 'promoted' at the begining via {@link #mPromotedSources}. These |
| * are the sources that will get their results shown at the top. However, if a promoted source |
| * fails to report within {@link #mPromotedSourceDeadline}, they will be removed from the promoted |
| * list, and only shown in the "more results" section. |
| */ |
| public class SourceSuggestionBacker extends SuggestionBacker { |
| |
| private static final boolean DBG = false; |
| private static final String TAG = "GlobalSearch"; |
| private int mIndexOfMore; |
| private boolean mShowingMore; |
| |
| private final String mQuery; |
| |
| interface MoreExpanderFactory { |
| |
| /** |
| * @param expanded Whether the entry should appear expanded. |
| * @param sourceStats The entries that will appear under "more results". |
| * @return An entry that will hold the "more results" toggle / expander. |
| */ |
| SuggestionData getMoreEntry(boolean expanded, List<SourceStat> sourceStats); |
| } |
| |
| interface CorpusResultFactory { |
| |
| /** |
| * Creates a result to be shown representing the results available for a corpus. |
| * |
| * @param query The query |
| * @param sourceStat Information about the source. |
| * @return A result displaying this information. |
| */ |
| SuggestionData getCorpusEntry(String query, SourceStat sourceStat); |
| } |
| |
| private final List<SuggestionSource> mSources; |
| private final HashSet<ComponentName> mPromotedSources; |
| private final SuggestionSource mSelectedWebSearchSource; |
| private final SuggestionData mSearchTheWebSuggestion; |
| private final MoreExpanderFactory mMoreFactory; |
| private final CorpusResultFactory mCorpusFactory; |
| private final long mPromotedSourceDeadline; |
| private long mPromotedQueryStartTime; |
| |
| // The source suggestions that we have already committed to showing |
| private final ArrayList<SuggestionData> mCommitted; |
| // The number of slots remaining after the committed results |
| private int mSlotsRemaining; |
| // The chunk size to use for the timely promoted results |
| private final int mTopChunkSize; |
| // Whether we have added the rest of the timely promoted results |
| // after the initial chunks |
| private boolean mFilledRest = false; |
| private final CounterMap<ComponentName> mSourceToNumDisplayed |
| = new CounterMap<ComponentName>(); |
| |
| // The suggestion to pin to the bottom of the list, if any, coming from the web search source. |
| // This is used by the Google search provider to pin a "Manage search history" item to the |
| // bottom whenever we show search history related suggestions. |
| private SuggestionData mPinToBottomSuggestion; |
| |
| private final LinkedHashMap<ComponentName, SuggestionResult> mReportedResults = |
| new LinkedHashMap<ComponentName, SuggestionResult>(); |
| private final HashSet<ComponentName> mReportedBeforeDeadline |
| = new HashSet<ComponentName>(); |
| private final ArrayList<Iterator<SuggestionData>> mTimelyPromotedRemaining = |
| new ArrayList<Iterator<SuggestionData>>(); |
| |
| private final HashSet<String> mSuggestionKeys = new HashSet<String>(); |
| |
| private final HashSet<ComponentName> mPendingSources |
| = new HashSet<ComponentName>(); |
| |
| // non-promoted sources that have been viewed (snapshotted while "more" was expanded). |
| // we keep track of this because we never want to remove a source that we have shown |
| // before, even if they end up having zero results |
| private final HashSet<ComponentName> mViewedNonPromoted |
| = new HashSet<ComponentName>(); |
| |
| /** |
| * @param query |
| * @param shortcuts To be shown at top. |
| * @param sources The sources that are either part of the cached results, or that are expected |
| * to report. |
| * @param promotedSources The promoted sources expecting to report |
| * @param selectedWebSearchSource the currently selected web search source |
| * @param cachedResults Any results we are already privy to |
| * @param goToWebsiteSuggestion The "go to website" entry to show if appropriate |
| * @param searchTheWebSuggestion The "search the web" entry to show if appropriate |
| * @param maxPromotedSlots The maximum numer of results to show for the promoted sources |
| * @param promotedSourceDeadline How long to wait for the promoted sources before mixing in the |
| * results and displaying the "search the web" and "more results" entries. |
| * @param moreFactory How to create the expander entry |
| * @param corpusFactory How to create results for each corpus |
| */ |
| public SourceSuggestionBacker( |
| String query, |
| List<SuggestionData> shortcuts, |
| List<SuggestionSource> sources, |
| HashSet<ComponentName> promotedSources, |
| SuggestionSource selectedWebSearchSource, |
| Collection<SuggestionResult> cachedResults, |
| SuggestionData goToWebsiteSuggestion, |
| SuggestionData searchTheWebSuggestion, |
| int maxPromotedSlots, |
| long promotedSourceDeadline, |
| MoreExpanderFactory moreFactory, |
| CorpusResultFactory corpusFactory) { |
| |
| if (promotedSources.size() > maxPromotedSlots) { |
| throw new IllegalArgumentException("more promoted sources than there are slots " + |
| "provided"); |
| } |
| |
| mQuery = query; |
| mSearchTheWebSuggestion = searchTheWebSuggestion; |
| mMoreFactory = moreFactory; |
| mPromotedSourceDeadline = promotedSourceDeadline; |
| mCorpusFactory = corpusFactory; |
| mSources = sources; |
| mPromotedSources = promotedSources; |
| mSelectedWebSearchSource = selectedWebSearchSource; |
| |
| mCommitted = new ArrayList<SuggestionData>(maxPromotedSlots); |
| if (goToWebsiteSuggestion != null) { |
| mCommitted.add(goToWebsiteSuggestion); |
| } |
| mCommitted.addAll(shortcuts); |
| mSlotsRemaining = maxPromotedSlots - shortcuts.size(); |
| if (DBG) { |
| Log.d(TAG, "Committed " + shortcuts.size() + " shortcuts, " |
| + mSlotsRemaining + " slots remaining"); |
| } |
| mTopChunkSize = calcChunkSize(mSlotsRemaining, mPromotedSources.size()); |
| |
| mPromotedQueryStartTime = getNow(); |
| |
| final int numShortcuts = shortcuts.size(); |
| for (int i = 0; i < numShortcuts; i++) { |
| final SuggestionData shortcut = shortcuts.get(i); |
| mSuggestionKeys.add(makeSuggestionKey(shortcut)); |
| } |
| |
| for (SuggestionResult cachedResult : cachedResults) { |
| addSourceResults(cachedResult); |
| } |
| } |
| |
| /** |
| * Sets the time that the promoted sources were queried, if different from the creation |
| * time. This is necessary when the backer is created, but the sources are queried after |
| * a delay. |
| */ |
| public synchronized void reportPromotedQueryStartTime() { |
| mPromotedQueryStartTime = getNow(); |
| } |
| |
| /** |
| * @return Whether the deadline has passed for promoted sources to report before mixing in |
| * the rest of the results and displaying the "search the web" and "more results" entries. |
| */ |
| private boolean isPastDeadline() { |
| return getNow() - mPromotedQueryStartTime >= mPromotedSourceDeadline; |
| } |
| |
| @Override |
| public synchronized void snapshotSuggestions( |
| ArrayList<SuggestionData> dest, boolean expandAdditional) { |
| if (DBG) Log.d(TAG, "snapShotSuggestions"); |
| mIndexOfMore = snapshotSuggestionsInternal(dest, expandAdditional); |
| } |
| |
| /** |
| * @return the index of the "more results" entry, or if there is no "more results" entry, |
| * something large enough so that the index will never be requested (e.g the size). |
| */ |
| private int snapshotSuggestionsInternal( |
| ArrayList<SuggestionData> dest, boolean expandAdditional) { |
| dest.clear(); |
| |
| boolean shortcutsOnly = mSources.isEmpty(); |
| boolean promotedDone = allPromotedResponded(); |
| boolean shouldFill = |
| (isPastDeadline() || mSlotsRemaining <= 0 || promotedDone) && !shortcutsOnly; |
| boolean shouldShowMore = (mSlotsRemaining <= 0 || promotedDone) && !shortcutsOnly; |
| |
| // This is only done once, since it adds the suggestions to mCommitted permanently |
| if (shouldFill && !mFilledRest) { |
| fillRest(); |
| // commit "search the web" |
| if (mSearchTheWebSuggestion != null) { |
| if (DBG) Log.d(TAG, "snapshot: adding 'search the web'"); |
| mCommitted.add(mSearchTheWebSuggestion); |
| } |
| mFilledRest = true; |
| } |
| |
| dest.addAll(mCommitted); |
| |
| if (shouldShowMore) { |
| // Add a pin-to-bottom suggestion if one has been found |
| if (mPinToBottomSuggestion != null) { |
| if (DBG) Log.d(TAG, "snapshot: adding a pin-to-bottom suggestion"); |
| dest.add(mPinToBottomSuggestion); |
| } |
| |
| // gather stats about sources so we can properly construct "more" ui |
| ArrayList<SourceStat> moreSources = getMoreStats(); |
| // add "more results" if applicable |
| int indexOfMore = dest.size(); |
| if (shouldMoreBeVisible(moreSources)) { |
| if (DBG) Log.d(TAG, "snapshot: adding 'more results' expander"); |
| mShowingMore = true; |
| dest.add(mMoreFactory.getMoreEntry(expandAdditional, moreSources)); |
| if (expandAdditional) { |
| final int moreCount = moreSources.size(); |
| for (int i = 0; i < moreCount; i++) { |
| final SourceStat moreSource = moreSources.get(i); |
| if (shouldCorpusEntryBeVisible(moreSource)) { |
| if (DBG) Log.d(TAG, "snapshot: adding 'more' " + moreSource.getLabel()); |
| dest.add(mCorpusFactory.getCorpusEntry(mQuery, moreSource)); |
| mViewedNonPromoted.add(moreSource.getName()); |
| } |
| } |
| } |
| } |
| return indexOfMore; |
| } |
| return dest.size(); |
| } |
| |
| private boolean allPromotedResponded() { |
| return mReportedResults.size() >= mPromotedSources.size(); |
| } |
| |
| private ArrayList<SourceStat> getMoreStats() { |
| ArrayList<SourceStat> moreSources = new ArrayList<SourceStat>(); |
| for (SuggestionSource source : mSources) { |
| final boolean promoted = mPromotedSources.contains(source.getComponentName()); |
| final SuggestionResult sourceResult = |
| mReportedResults.get(source.getComponentName()); |
| final boolean beforeDeadline = |
| mReportedBeforeDeadline.contains(source.getComponentName()); |
| |
| if (sourceResult == null) { |
| // sources that haven't reported yet |
| final int responseStatus = |
| mPendingSources.contains(source.getComponentName()) ? |
| SourceStat.RESPONSE_IN_PROGRESS : |
| SourceStat.RESPONSE_NOT_STARTED; |
| moreSources.add(new SourceStat( |
| source.getComponentName(), promoted, source.getLabel(), |
| source.getIcon(), responseStatus, 0, 0)); |
| } else if (beforeDeadline && promoted) { |
| int numDisplayed = mSourceToNumDisplayed.readCounter(source.getComponentName()); |
| |
| // sources are only in "more" if they have undisplayed results |
| if (numDisplayed < sourceResult.getSuggestions().size()) { |
| // Decrement the number of results remaining by one if one of them |
| // is a pin-to-bottom suggestion from the web search source (since the |
| // pin to bottom will always be from the web source) |
| int numResultsRemaining = sourceResult.getCount() - numDisplayed; |
| int queryLimit = sourceResult.getQueryLimit() - numDisplayed; |
| if (mPinToBottomSuggestion != null && isWebSuggestionSource(source)) { |
| numResultsRemaining--; |
| queryLimit--; |
| } |
| |
| moreSources.add( |
| new SourceStat( |
| source.getComponentName(), |
| promoted, |
| source.getLabel(), |
| source.getIcon(), |
| SourceStat.RESPONSE_FINISHED, |
| numResultsRemaining, |
| queryLimit)); |
| } |
| } else { |
| // unpromoted sources that have reported |
| moreSources.add( |
| new SourceStat( |
| source.getComponentName(), |
| false, |
| source.getLabel(), |
| source.getIcon(), |
| SourceStat.RESPONSE_FINISHED, |
| sourceResult.getCount(), |
| sourceResult.getQueryLimit())); |
| } |
| } |
| return moreSources; |
| } |
| |
| private static class CounterMap<A> extends HashMap<A, Integer> { |
| public int readCounter(A key) { |
| Integer count = get(key); |
| return count == null ? 0 : count.intValue(); |
| } |
| public void incrementCounter(A key) { |
| int old = readCounter(key); |
| put(key, old + 1); |
| } |
| } |
| |
| private int calcChunkSize(int slotsRemaining, int sourceCount) { |
| if (sourceCount == 0) return 0; |
| return Math.max(1, slotsRemaining / sourceCount); |
| } |
| |
| private boolean shouldMoreBeVisible(ArrayList<SourceStat> sourceStats) { |
| final int num = sourceStats.size(); |
| for (int i = 0; i < num; i++) { |
| final SourceStat moreSource = sourceStats.get(i); |
| if (shouldCorpusEntryBeVisible(moreSource)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * @param sourceStat A corpus result stat |
| * @return True if it should be visible. |
| */ |
| private boolean shouldCorpusEntryBeVisible(SourceStat sourceStat) { |
| return sourceStat.getNumResults() > 0 |
| || sourceStat.getResponseStatus() != SourceStat.RESPONSE_FINISHED |
| || mViewedNonPromoted.contains(sourceStat.getName()); |
| } |
| |
| private String makeSuggestionKey(SuggestionData suggestion) { |
| // calculating accurate size of string builder avoids an allocation vs starting with |
| // the default size and having to expand. |
| final String action = suggestion.getIntentAction() == null ? |
| "none" : suggestion.getIntentAction(); |
| final String intentData = suggestion.getIntentData() == null ? |
| "none" : suggestion.getIntentData(); |
| final String intentQuery = suggestion.getIntentQuery() == null ? |
| "" : suggestion.getIntentQuery(); |
| final int alloc = action.length() + 2 + intentData.length() + intentQuery.length(); |
| return new StringBuilder(alloc) |
| .append(suggestion.getIntentAction()) |
| .append('#') |
| .append(suggestion.getIntentData()) |
| .append('#') |
| .append(suggestion.getIntentQuery()) |
| .toString(); |
| } |
| |
| @Override |
| public synchronized boolean reportSourceStarted(ComponentName source) { |
| mPendingSources.add(source); |
| |
| // only refresh if it is one of the non-promoted sources, since that is the only case |
| // where it currently results in the UI changing (the progress icon starts for the |
| // corresponding corpus result). |
| return !mPromotedSources.contains(source); |
| } |
| |
| @Override |
| public boolean hasSourceStarted(ComponentName source) { |
| return mReportedResults.containsKey(source); |
| } |
| |
| @Override |
| protected synchronized boolean addSourceResults(SuggestionResult suggestionResult) { |
| final SuggestionSource source = suggestionResult.getSource(); |
| final List<SuggestionData> suggestions = suggestionResult.getSuggestions(); |
| final boolean pastDeadline = isPastDeadline(); |
| |
| if (DBG) { |
| Log.d(TAG, source.getComponentName() + " reported " + suggestions.size() |
| + " suggestions"); |
| } |
| |
| // If the source is the web search source and there is a pin-to-bottom suggestion at |
| // the end of the list of suggestions, store it separately, remove it from the list, |
| // and keep going. The stored suggestion will be added to the very bottom of the list |
| // in snapshotSuggestions. |
| SuggestionData pinToBottomSuggestion = null; |
| if (isWebSuggestionSource(source)) { |
| if (!suggestions.isEmpty()) { |
| int lastPosition = suggestions.size() - 1; |
| SuggestionData lastSuggestion = suggestions.get(lastPosition); |
| if (lastSuggestion.isPinToBottom()) { |
| pinToBottomSuggestion = lastSuggestion; |
| if (DBG) { |
| Log.d(TAG, "Found pin-to-bottom suggestion: " + pinToBottomSuggestion); |
| } |
| suggestions.remove(lastPosition); |
| } |
| } |
| } |
| // no longer pending |
| mPendingSources.remove(source.getComponentName()); |
| |
| // prune down dupes if necessary |
| final Iterator<SuggestionData> it = suggestions.iterator(); |
| while (it.hasNext()) { |
| SuggestionData s = it.next(); |
| final String key = makeSuggestionKey(s); |
| if (mSuggestionKeys.contains(key)) { |
| if (DBG) Log.d(TAG, "Removing duplicate suggestion " + s); |
| it.remove(); |
| } else { |
| mSuggestionKeys.add(key); |
| } |
| } |
| |
| mReportedResults.put(source.getComponentName(), suggestionResult); |
| |
| // handle filling in promoted results |
| boolean addedPromoted = false; |
| if (mPromotedSources.contains(source.getComponentName())) { |
| Iterator<SuggestionData> result = suggestionResult.getSuggestions().iterator(); |
| // we extend the deadline until the "More results" suggestion is shown |
| if (!mShowingMore) { |
| mReportedBeforeDeadline.add(source.getComponentName()); |
| // If we have already used all reported promoted suggestions, take all slots |
| int chunkSize = mFilledRest ? mSlotsRemaining : mTopChunkSize; |
| addedPromoted = addChunk(result, chunkSize); |
| if (result.hasNext()) { |
| mTimelyPromotedRemaining.add(result); |
| } |
| } |
| } |
| // If any suggestions from this source were added, make sure to show the |
| // pin-to-bottom suggestion |
| if (addedPromoted && pinToBottomSuggestion != null) { |
| if (DBG) { |
| Log.d(TAG, "Will show pin-to-bottom suggestion: " + pinToBottomSuggestion); |
| } |
| mPinToBottomSuggestion = pinToBottomSuggestion; |
| } |
| |
| return pastDeadline || addedPromoted || allPromotedResponded(); |
| } |
| |
| /** |
| * Fills the rest of the slots with results from {@link #mTimelyPromotedRemaining}. |
| */ |
| private void fillRest() { |
| if (mSlotsRemaining <= 0) { |
| if (DBG) Log.d(TAG, "No slots to mix in extra promtoed results."); |
| return; |
| } |
| final int resultCount = mTimelyPromotedRemaining.size(); |
| if (resultCount == 0) { |
| if (DBG) Log.d(TAG, "No promoted results left."); |
| return; |
| } |
| if (DBG) Log.d(TAG, "mixing in rest of results from " + resultCount + " sources"); |
| final int chunkSize = calcChunkSize(mSlotsRemaining, resultCount); |
| for (int j = 0; j < resultCount; j++) { |
| addChunk(mTimelyPromotedRemaining.get(j), chunkSize); |
| } |
| } |
| |
| private boolean addChunk(Iterator<SuggestionData> result, int chunkSize) { |
| boolean changed = false; |
| for (int i = 0; i < chunkSize && result.hasNext(); i++) { |
| if (mSlotsRemaining <= 0) return changed; |
| final SuggestionData suggestionData = result.next(); |
| if (DBG) { |
| Log.d(TAG, "Committing (" + (mSlotsRemaining-1) + " slots left) " |
| + suggestionData); |
| } |
| mCommitted.add(suggestionData); |
| mSourceToNumDisplayed.incrementCounter(suggestionData.getSource()); |
| mSlotsRemaining--; |
| changed = true; |
| } |
| return changed; |
| } |
| |
| /** |
| * Compares the provided source to the selected web search source. |
| */ |
| private boolean isWebSuggestionSource(SuggestionSource source) { |
| return mSelectedWebSearchSource != null && |
| source.getComponentName().equals(mSelectedWebSearchSource.getComponentName()); |
| } |
| |
| @Override |
| protected synchronized boolean refreshShortcut( |
| ComponentName source, String shortcutId, SuggestionData refreshed) { |
| final int size = mCommitted.size(); |
| for (int i = 0; i < size; i++) { |
| final SuggestionData shortcut = mCommitted.get(i); |
| if (shortcutId.equals(shortcut.getShortcutId())) { |
| if (refreshed == null) { |
| // If we're removing this shortcut, we still need to stop the spinner in |
| // the icon2 value of any shortcut which was set to spin while refreshing. |
| if (shortcut.isSpinnerWhileRefreshing()) { |
| mCommitted.set(i, shortcut.buildUpon().icon2(null).build()); |
| return true; |
| } else { |
| return false; |
| } |
| } else { |
| mCommitted.set(i, refreshed); |
| } |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| @Override |
| public synchronized boolean isResultsPending() { |
| return !allPromotedResponded(); |
| } |
| |
| @Override |
| public boolean isShowingMore() { |
| return mShowingMore; |
| } |
| |
| @Override |
| public int getMoreResultPosition() { |
| return mIndexOfMore; |
| } |
| |
| /** |
| * Stats about a particular source that includes enough information to properly display |
| * "more results" entries. |
| */ |
| static class SourceStat { |
| static final int RESPONSE_NOT_STARTED = 77; |
| static final int RESPONSE_IN_PROGRESS = 78; |
| static final int RESPONSE_FINISHED = 79; |
| |
| private final ComponentName mName; |
| private final boolean mShowingPromotedResults; |
| private final String mLabel; |
| private final String mIcon; |
| private final int mResponseStatus; |
| private final int mNumResults; |
| private final int mQueryLimit; |
| |
| /** |
| * @param name The component name of the source. |
| * @param showingPromotedResults Whether this source has anything showing in the promoted |
| * slots. |
| * @param label The label. |
| * @param icon The icon. |
| * @param responseStatus Whether it has responded. |
| * @param numResults The number of results (if applicable). |
| * @param queryLimit The number of results requested from the source. |
| */ |
| SourceStat(ComponentName name, boolean showingPromotedResults, String label, String icon, |
| int responseStatus, int numResults, int queryLimit) { |
| switch (responseStatus) { |
| case RESPONSE_NOT_STARTED: |
| case RESPONSE_IN_PROGRESS: |
| case RESPONSE_FINISHED: |
| break; |
| default: |
| throw new IllegalArgumentException("invalid response status"); |
| } |
| |
| this.mName = name; |
| mShowingPromotedResults = showingPromotedResults; |
| this.mLabel = label; |
| this.mIcon = icon; |
| this.mResponseStatus = responseStatus; |
| this.mNumResults = numResults; |
| mQueryLimit = queryLimit; |
| } |
| |
| public ComponentName getName() { |
| return mName; |
| } |
| |
| public boolean isShowingPromotedResults() { |
| return mShowingPromotedResults; |
| } |
| |
| public String getLabel() { |
| return mLabel; |
| } |
| |
| public String getIcon() { |
| return mIcon; |
| } |
| |
| public int getResponseStatus() { |
| return mResponseStatus; |
| } |
| |
| public int getNumResults() { |
| return mNumResults; |
| } |
| |
| public int getQueryLimit() { |
| return mQueryLimit; |
| } |
| } |
| } |