1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 *
16 */
17
18package com.android.settings.search;
19
20import android.content.Context;
21import android.os.Handler;
22import android.os.Looper;
23import android.os.Message;
24import android.support.annotation.IntDef;
25import android.support.annotation.MainThread;
26import android.support.annotation.VisibleForTesting;
27import android.support.v7.util.DiffUtil;
28import android.support.v7.widget.RecyclerView;
29import android.util.ArrayMap;
30import android.util.Log;
31import android.util.Pair;
32import android.view.LayoutInflater;
33import android.view.View;
34import android.view.ViewGroup;
35
36import com.android.settings.R;
37import com.android.settings.search.ranking.SearchResultsRankerCallback;
38
39import java.lang.annotation.Retention;
40import java.lang.annotation.RetentionPolicy;
41import java.util.ArrayList;
42import java.util.Collections;
43import java.util.Comparator;
44import java.util.HashSet;
45import java.util.List;
46import java.util.Map;
47import java.util.Set;
48import java.util.TreeSet;
49
50public class SearchResultsAdapter extends RecyclerView.Adapter<SearchViewHolder>
51        implements SearchResultsRankerCallback {
52    private static final String TAG = "SearchResultsAdapter";
53
54    @VisibleForTesting
55    static final String DB_RESULTS_LOADER_KEY = DatabaseResultLoader.class.getName();
56
57    @VisibleForTesting
58    static final String APP_RESULTS_LOADER_KEY = InstalledAppResultLoader.class.getName();
59    @VisibleForTesting
60    static final String ACCESSIBILITY_LOADER_KEY = AccessibilityServiceResultLoader.class.getName();
61    @VisibleForTesting
62    static final String INPUT_DEVICE_LOADER_KEY = InputDeviceResultLoader.class.getName();
63
64    @VisibleForTesting
65    static final int MSG_RANKING_TIMED_OUT = 1;
66
67    private final SearchFragment mFragment;
68    private final Context mContext;
69    private final List<SearchResult> mSearchResults;
70    private final List<SearchResult> mStaticallyRankedSearchResults;
71    private Map<String, Set<? extends SearchResult>> mResultsMap;
72    private final SearchFeatureProvider mSearchFeatureProvider;
73    private List<Pair<String, Float>> mSearchRankingScores;
74    private Handler mHandler;
75    private boolean mSearchResultsLoaded;
76    private boolean mSearchResultsUpdated;
77
78    @IntDef({DISABLED, PENDING_RESULTS, SUCCEEDED, FAILED, TIMED_OUT})
79    @Retention(RetentionPolicy.SOURCE)
80    private @interface AsyncRankingState {}
81    @VisibleForTesting
82    static final int DISABLED = 0;
83    @VisibleForTesting
84    static final int PENDING_RESULTS = 1;
85    @VisibleForTesting
86    static final int SUCCEEDED = 2;
87    @VisibleForTesting
88    static final int FAILED = 3;
89    @VisibleForTesting
90    static final int TIMED_OUT = 4;
91    private @AsyncRankingState int mAsyncRankingState;
92
93    public SearchResultsAdapter(SearchFragment fragment,
94            SearchFeatureProvider searchFeatureProvider) {
95        mFragment = fragment;
96        mContext = fragment.getContext().getApplicationContext();
97        mSearchResults = new ArrayList<>();
98        mResultsMap = new ArrayMap<>();
99        mSearchRankingScores = new ArrayList<>();
100        mStaticallyRankedSearchResults = new ArrayList<>();
101        mSearchFeatureProvider = searchFeatureProvider;
102
103        setHasStableIds(true);
104    }
105
106    @Override
107    public SearchViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
108        final Context context = parent.getContext();
109        final LayoutInflater inflater = LayoutInflater.from(context);
110        final View view;
111        switch (viewType) {
112            case ResultPayload.PayloadType.INTENT:
113                view = inflater.inflate(R.layout.search_intent_item, parent, false);
114                return new IntentSearchViewHolder(view);
115            case ResultPayload.PayloadType.INLINE_SWITCH:
116                // TODO (b/62807132) replace layout InlineSwitchViewHolder and return an
117                // InlineSwitchViewHolder.
118                view = inflater.inflate(R.layout.search_intent_item, parent, false);
119                return new IntentSearchViewHolder(view);
120            case ResultPayload.PayloadType.INLINE_LIST:
121                // TODO (b/62807132) build a inline-list view holder & layout.
122                view = inflater.inflate(R.layout.search_intent_item, parent, false);
123                return new IntentSearchViewHolder(view);
124            case ResultPayload.PayloadType.SAVED_QUERY:
125                view = inflater.inflate(R.layout.search_saved_query_item, parent, false);
126                return new SavedQueryViewHolder(view);
127            default:
128                return null;
129        }
130    }
131
132    @Override
133    public void onBindViewHolder(SearchViewHolder holder, int position) {
134        holder.onBind(mFragment, mSearchResults.get(position));
135    }
136
137    @Override
138    public long getItemId(int position) {
139        return mSearchResults.get(position).stableId;
140    }
141
142    @Override
143    public int getItemViewType(int position) {
144        return mSearchResults.get(position).viewType;
145    }
146
147    @Override
148    public int getItemCount() {
149        return mSearchResults.size();
150    }
151
152    @MainThread
153    @Override
154    public void onRankingScoresAvailable(List<Pair<String, Float>> searchRankingScores) {
155        // Received the scores, stop the timeout timer.
156        getHandler().removeMessages(MSG_RANKING_TIMED_OUT);
157        if (mAsyncRankingState == PENDING_RESULTS) {
158            mAsyncRankingState = SUCCEEDED;
159            mSearchRankingScores.clear();
160            mSearchRankingScores.addAll(searchRankingScores);
161            if (canUpdateSearchResults()) {
162                updateSearchResults();
163            }
164        } else {
165            Log.w(TAG, "Ranking scores became available in invalid state: " + mAsyncRankingState);
166        }
167    }
168
169    @MainThread
170    @Override
171    public void onRankingFailed() {
172        if (mAsyncRankingState == PENDING_RESULTS) {
173            mAsyncRankingState = FAILED;
174            if (canUpdateSearchResults()) {
175                updateSearchResults();
176            }
177        } else {
178            Log.w(TAG, "Ranking scores failed in invalid states: " + mAsyncRankingState);
179        }
180    }
181
182   /**
183     * Store the results from each of the loaders to be merged when all loaders are finished.
184     *
185     * @param results         the results from the loader.
186     * @param loaderClassName class name of the loader.
187     */
188    @MainThread
189    public void addSearchResults(Set<? extends SearchResult> results, String loaderClassName) {
190        if (results == null) {
191            return;
192        }
193        mResultsMap.put(loaderClassName, results);
194    }
195
196    /**
197     * Displays recent searched queries.
198     *
199     * @return The number of saved queries to display
200     */
201    public int displaySavedQuery(List<? extends SearchResult> data) {
202        clearResults();
203        mSearchResults.addAll(data);
204        notifyDataSetChanged();
205        return mSearchResults.size();
206    }
207
208    /**
209     * Notifies the adapter that all the unsorted results are loaded and now the ladapter can
210     * proceed with ranking the results.
211     */
212    @MainThread
213    public void notifyResultsLoaded() {
214        mSearchResultsLoaded = true;
215        // static ranking is skipped only if asyc ranking is already succeeded.
216        if (mAsyncRankingState != SUCCEEDED) {
217            doStaticRanking();
218        }
219        if (canUpdateSearchResults()) {
220            updateSearchResults();
221        }
222    }
223
224    public void clearResults() {
225        mSearchResults.clear();
226        mStaticallyRankedSearchResults.clear();
227        mResultsMap.clear();
228        notifyDataSetChanged();
229    }
230
231    @VisibleForTesting
232    public List<SearchResult> getSearchResults() {
233        return mSearchResults;
234    }
235
236    @MainThread
237    public void initializeSearch(String query) {
238        clearResults();
239        mSearchResultsLoaded = false;
240        mSearchResultsUpdated = false;
241        if (mSearchFeatureProvider.isSmartSearchRankingEnabled(mContext)) {
242            mAsyncRankingState = PENDING_RESULTS;
243            mSearchFeatureProvider.cancelPendingSearchQuery(mContext);
244            final Handler handler = getHandler();
245            final long timeoutMs = mSearchFeatureProvider.smartSearchRankingTimeoutMs(mContext);
246            handler.sendMessageDelayed(
247                    handler.obtainMessage(MSG_RANKING_TIMED_OUT), timeoutMs);
248            mSearchFeatureProvider.querySearchResults(mContext, query, this);
249        } else {
250            mAsyncRankingState = DISABLED;
251        }
252    }
253
254    @AsyncRankingState int getAsyncRankingState() {
255        return mAsyncRankingState;
256    }
257
258    /**
259     * Merge the results from each of the loaders into one list for the adapter.
260     * Prioritizes results from the local database over installed apps.
261     */
262    private void doStaticRanking() {
263        List<? extends SearchResult> databaseResults =
264                getSortedLoadedResults(DB_RESULTS_LOADER_KEY);
265        List<? extends SearchResult> installedAppResults =
266                getSortedLoadedResults(APP_RESULTS_LOADER_KEY);
267        List<? extends SearchResult> accessibilityResults =
268                getSortedLoadedResults(ACCESSIBILITY_LOADER_KEY);
269        List<? extends SearchResult> inputDeviceResults =
270                getSortedLoadedResults(INPUT_DEVICE_LOADER_KEY);
271
272        int dbSize = databaseResults.size();
273        int appSize = installedAppResults.size();
274        int a11ySize = accessibilityResults.size();
275        int inputDeviceSize = inputDeviceResults.size();
276        int dbIndex = 0;
277        int appIndex = 0;
278        int a11yIndex = 0;
279        int inputDeviceIndex = 0;
280        int rank = SearchResult.TOP_RANK;
281
282        // TODO: We need a helper method to do k-way merge.
283        mStaticallyRankedSearchResults.clear();
284        while (rank <= SearchResult.BOTTOM_RANK) {
285            while ((dbIndex < dbSize) && (databaseResults.get(dbIndex).rank == rank)) {
286                mStaticallyRankedSearchResults.add(databaseResults.get(dbIndex++));
287            }
288            while ((appIndex < appSize) && (installedAppResults.get(appIndex).rank == rank)) {
289                mStaticallyRankedSearchResults.add(installedAppResults.get(appIndex++));
290            }
291            while ((a11yIndex < a11ySize) && (accessibilityResults.get(a11yIndex).rank == rank)) {
292                mStaticallyRankedSearchResults.add(accessibilityResults.get(a11yIndex++));
293            }
294            while (inputDeviceIndex < inputDeviceSize
295                    && inputDeviceResults.get(inputDeviceIndex).rank == rank) {
296                mStaticallyRankedSearchResults.add(inputDeviceResults.get(inputDeviceIndex++));
297            }
298            rank++;
299        }
300
301        while (dbIndex < dbSize) {
302            mStaticallyRankedSearchResults.add(databaseResults.get(dbIndex++));
303        }
304        while (appIndex < appSize) {
305            mStaticallyRankedSearchResults.add(installedAppResults.get(appIndex++));
306        }
307        while(a11yIndex < a11ySize) {
308            mStaticallyRankedSearchResults.add(accessibilityResults.get(a11yIndex++));
309        }
310        while (inputDeviceIndex < inputDeviceSize) {
311            mStaticallyRankedSearchResults.add(inputDeviceResults.get(inputDeviceIndex++));
312        }
313    }
314
315    private void updateSearchResults() {
316        switch (mAsyncRankingState) {
317            case PENDING_RESULTS:
318                break;
319            case DISABLED:
320            case FAILED:
321            case TIMED_OUT:
322                // When DISABLED or FAILED or TIMED_OUT, we use static ranking results.
323                postSearchResults(mStaticallyRankedSearchResults, false);
324                break;
325            case SUCCEEDED:
326                postSearchResults(doAsyncRanking(), true);
327                break;
328        }
329    }
330
331    private boolean canUpdateSearchResults() {
332        // Results are not updated yet and db results are loaded and we are not waiting on async
333        // ranking scores.
334        return !mSearchResultsUpdated
335                && mSearchResultsLoaded
336                && mAsyncRankingState != PENDING_RESULTS;
337    }
338
339    @VisibleForTesting
340    List<SearchResult> doAsyncRanking() {
341        Set<? extends SearchResult> databaseResults =
342                getUnsortedLoadedResults(DB_RESULTS_LOADER_KEY);
343        List<? extends SearchResult> installedAppResults =
344                getSortedLoadedResults(APP_RESULTS_LOADER_KEY);
345        List<? extends SearchResult> accessibilityResults =
346                getSortedLoadedResults(ACCESSIBILITY_LOADER_KEY);
347        List<? extends SearchResult> inputDeviceResults =
348                getSortedLoadedResults(INPUT_DEVICE_LOADER_KEY);
349        int dbSize = databaseResults.size();
350        int appSize = installedAppResults.size();
351        int a11ySize = accessibilityResults.size();
352        int inputDeviceSize = inputDeviceResults.size();
353
354        final List<SearchResult> asyncRankingResults = new ArrayList<>(
355                dbSize + appSize + a11ySize + inputDeviceSize);
356        TreeSet<SearchResult> dbResultsSortedByScores = new TreeSet<>(
357                new Comparator<SearchResult>() {
358                    @Override
359                    public int compare(SearchResult o1, SearchResult o2) {
360                        float score1 = getRankingScoreByStableId(o1.stableId);
361                        float score2 = getRankingScoreByStableId(o2.stableId);
362                        if (score1 > score2) {
363                            return -1;
364                        } else if (score1 == score2) {
365                            return 0;
366                        } else {
367                            return 1;
368                        }
369                    }
370                });
371        dbResultsSortedByScores.addAll(databaseResults);
372        asyncRankingResults.addAll(dbResultsSortedByScores);
373        // Other results are not ranked by async ranking and appended at the end of the list.
374        asyncRankingResults.addAll(installedAppResults);
375        asyncRankingResults.addAll(accessibilityResults);
376        asyncRankingResults.addAll(inputDeviceResults);
377        return asyncRankingResults;
378    }
379
380    @VisibleForTesting
381    Set<? extends SearchResult> getUnsortedLoadedResults(String loaderKey) {
382        return mResultsMap.containsKey(loaderKey) ? mResultsMap.get(loaderKey) : new HashSet<>();
383    }
384
385    @VisibleForTesting
386    List<? extends SearchResult> getSortedLoadedResults(String loaderKey) {
387        List<? extends SearchResult> sortedLoadedResults =
388                new ArrayList<>(getUnsortedLoadedResults(loaderKey));
389        Collections.sort(sortedLoadedResults);
390        return sortedLoadedResults;
391    }
392
393    /**
394     * Looks up ranking score for stableId
395     * @param stableId String of stableId
396     * @return the ranking score corresponding to the given stableId. If there is no score
397     * available for this stableId, -Float.MAX_VALUE is returned.
398     */
399    @VisibleForTesting
400    Float getRankingScoreByStableId(int stableId) {
401        for (Pair<String, Float> rankingScore : mSearchRankingScores) {
402            if (Integer.toString(stableId).compareTo(rankingScore.first) == 0) {
403                return rankingScore.second;
404            }
405        }
406        // If stableId not found in the list, we assign the minimum score so it will appear at
407        // the end of the list.
408        Log.w(TAG, "stableId " + stableId + " was not in the ranking scores.");
409        return -Float.MAX_VALUE;
410    }
411
412    @VisibleForTesting
413    Handler getHandler() {
414        if (mHandler == null) {
415            mHandler = new Handler(Looper.getMainLooper()) {
416                @Override
417                public void handleMessage(Message msg) {
418                    if (msg.what == MSG_RANKING_TIMED_OUT) {
419                        mSearchFeatureProvider.cancelPendingSearchQuery(mContext);
420                        if (mAsyncRankingState == PENDING_RESULTS) {
421                            mAsyncRankingState = TIMED_OUT;
422                            if (canUpdateSearchResults()) {
423                                updateSearchResults();
424                            }
425                        } else {
426                            Log.w(TAG, "Ranking scores timed out in invalid state: " +
427                                    mAsyncRankingState);
428                        }
429                    }
430                }
431            };
432        }
433        return mHandler;
434    }
435
436    @VisibleForTesting
437    public void postSearchResults(List<SearchResult> newSearchResults, boolean detectMoves) {
438        final DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(
439                new SearchResultDiffCallback(mSearchResults, newSearchResults), detectMoves);
440        mSearchResults.clear();
441        mSearchResults.addAll(newSearchResults);
442        diffResult.dispatchUpdatesTo(this);
443        mFragment.onSearchResultsDisplayed(mSearchResults.size());
444        mSearchResultsUpdated = true;
445    }
446}
447