TvProviderSearch.java revision ba5845f23b8fbc985890f892961abc8b39886611
1/*
2 * Copyright (C) 2015 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
17package com.android.tv.search;
18
19import android.content.ContentResolver;
20import android.content.Context;
21import android.content.Intent;
22import android.database.Cursor;
23import android.media.tv.TvContentRating;
24import android.media.tv.TvContract;
25import android.media.tv.TvContract.Channels;
26import android.media.tv.TvContract.Programs;
27import android.media.tv.TvContract.WatchedPrograms;
28import android.media.tv.TvInputInfo;
29import android.media.tv.TvInputManager;
30import android.net.Uri;
31import android.support.annotation.WorkerThread;
32import android.text.TextUtils;
33import android.util.Log;
34
35import com.android.tv.common.TvContentRatingCache;
36import com.android.tv.search.LocalSearchProvider.SearchResult;
37import com.android.tv.util.PermissionUtils;
38import com.android.tv.util.Utils;
39
40import junit.framework.Assert;
41
42import java.util.ArrayList;
43import java.util.Collections;
44import java.util.Comparator;
45import java.util.HashMap;
46import java.util.HashSet;
47import java.util.List;
48import java.util.Locale;
49import java.util.Map;
50import java.util.Objects;
51import java.util.Set;
52
53/**
54 * An implementation of {@link SearchInterface} to search query from TvProvider directly.
55 */
56public class TvProviderSearch implements SearchInterface {
57    private static final boolean DEBUG = false;
58    private static final String TAG = "TvProviderSearch";
59
60    private static final int NO_LIMIT = 0;
61
62    private final Context mContext;
63    private final ContentResolver mContentResolver;
64    private final TvInputManager mTvInputManager;
65    private final TvContentRatingCache mTvContentRatingCache = TvContentRatingCache.getInstance();
66
67    TvProviderSearch(Context context) {
68        mContext = context;
69        mContentResolver = context.getContentResolver();
70        mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
71    }
72
73    /**
74     * Search channels, inputs, or programs from TvProvider.
75     * This assumes that parental control settings will not be change while searching.
76     *
77     * @param action One of {@link #ACTION_TYPE_SWITCH_CHANNEL}, {@link #ACTION_TYPE_SWITCH_INPUT},
78     *               or {@link #ACTION_TYPE_AMBIGUOUS},
79     */
80    @Override
81    @WorkerThread
82    public List<SearchResult> search(String query, int limit, int action) {
83        List<SearchResult> results = new ArrayList<>();
84        if (!PermissionUtils.hasAccessAllEpg(mContext)) {
85            // TODO: support this feature for non-system LC app. b/23939816
86            return results;
87        }
88        Set<Long> channelsFound = new HashSet<>();
89        if (action == ACTION_TYPE_SWITCH_CHANNEL) {
90            results.addAll(searchChannels(query, channelsFound, limit));
91        } else if (action == ACTION_TYPE_SWITCH_INPUT) {
92            results.addAll(searchInputs(query, limit));
93        } else {
94            // Search channels first.
95            results.addAll(searchChannels(query, channelsFound, limit));
96            if (results.size() >= limit) {
97                return results;
98            }
99
100            // In case the user wanted to perform the action "switch to XXX", which is indicated by
101            // setting the limit to 1, search inputs.
102            if (limit == 1) {
103                results.addAll(searchInputs(query, limit));
104                if (!results.isEmpty()) {
105                    return results;
106                }
107            }
108
109            // Lastly, search programs.
110            limit -= results.size();
111            results.addAll(searchPrograms(query, null, new String[] {
112                    Programs.COLUMN_TITLE, Programs.COLUMN_SHORT_DESCRIPTION },
113                    channelsFound, limit));
114        }
115        return results;
116    }
117
118    private void appendSelectionString(StringBuilder sb, String[] columnForExactMatching,
119            String[] columnForPartialMatching) {
120        boolean firstColumn = true;
121        if (columnForExactMatching != null) {
122            for (String column : columnForExactMatching) {
123                if (!firstColumn) {
124                    sb.append(" OR ");
125                } else {
126                    firstColumn = false;
127                }
128                sb.append(column).append("=?");
129            }
130        }
131        if (columnForPartialMatching != null) {
132            for (String column : columnForPartialMatching) {
133                if (!firstColumn) {
134                    sb.append(" OR ");
135                } else {
136                    firstColumn = false;
137                }
138                sb.append(column).append(" LIKE ?");
139            }
140        }
141    }
142
143    private void insertSelectionArgumentStrings(String[] selectionArgs, int pos,
144            String query, String[] columnForExactMatching, String[] columnForPartialMatching) {
145        if (columnForExactMatching != null) {
146            int until = pos + columnForExactMatching.length;
147            for (; pos < until; ++pos) {
148                selectionArgs[pos] = query;
149            }
150        }
151        String selectionArg = "%" + query + "%";
152        if (columnForPartialMatching != null) {
153            int until = pos + columnForPartialMatching.length;
154            for (; pos < until; ++pos) {
155                selectionArgs[pos] = selectionArg;
156            }
157        }
158    }
159
160    @WorkerThread
161    private List<SearchResult> searchChannels(String query, Set<Long> channels, int limit) {
162        List<SearchResult> results = new ArrayList<>();
163        if (TextUtils.isDigitsOnly(query)) {
164            results.addAll(searchChannels(query, new String[] { Channels.COLUMN_DISPLAY_NUMBER },
165                    null, channels, NO_LIMIT));
166            if (results.size() > 1) {
167                Collections.sort(results, new ChannelComparatorWithSameDisplayNumber());
168            }
169        }
170        if (results.size() < limit) {
171            results.addAll(searchChannels(query, null,
172                    new String[] { Channels.COLUMN_DISPLAY_NAME, Channels.COLUMN_DESCRIPTION },
173                    channels, limit - results.size()));
174        }
175        if (results.size() > limit) {
176            results = results.subList(0, limit);
177        }
178        for (SearchResult result : results) {
179            fillProgramInfo(result);
180        }
181        return results;
182    }
183
184    @WorkerThread
185    private List<SearchResult> searchChannels(String query, String[] columnForExactMatching,
186            String[] columnForPartialMatching, Set<Long> channelsFound, int limit) {
187        Assert.assertTrue(
188                (columnForExactMatching != null && columnForExactMatching.length > 0) ||
189                (columnForPartialMatching != null && columnForPartialMatching.length > 0));
190
191        String[] projection = {
192                Channels._ID,
193                Channels.COLUMN_DISPLAY_NUMBER,
194                Channels.COLUMN_DISPLAY_NAME,
195                Channels.COLUMN_DESCRIPTION
196        };
197
198        StringBuilder sb = new StringBuilder();
199        sb.append(Channels.COLUMN_BROWSABLE).append("=1 AND ")
200                .append(Channels.COLUMN_SEARCHABLE).append("=1");
201        if (mTvInputManager.isParentalControlsEnabled()) {
202            sb.append(" AND ").append(Channels.COLUMN_LOCKED).append("=0");
203        }
204        sb.append(" AND (");
205        appendSelectionString(sb, columnForExactMatching, columnForPartialMatching);
206        sb.append(")");
207        String selection = sb.toString();
208
209        int len = (columnForExactMatching == null ? 0 : columnForExactMatching.length) +
210                (columnForPartialMatching == null ? 0 : columnForPartialMatching.length);
211        String[] selectionArgs = new String[len];
212        insertSelectionArgumentStrings(selectionArgs, 0, query, columnForExactMatching,
213                columnForPartialMatching);
214
215        List<SearchResult> searchResults = new ArrayList<>();
216
217        try (Cursor c = mContentResolver.query(Channels.CONTENT_URI, projection, selection,
218                selectionArgs, null)) {
219            if (c != null) {
220                int count = 0;
221                while (c.moveToNext()) {
222                    long id = c.getLong(0);
223                    // Filter out the channel which has been already searched.
224                    if (channelsFound.contains(id)) {
225                        continue;
226                    }
227                    channelsFound.add(id);
228
229                    SearchResult result = new SearchResult();
230                    result.channelId = id;
231                    result.channelNumber = c.getString(1);
232                    result.title = c.getString(2);
233                    result.description = c.getString(3);
234                    result.imageUri = TvContract.buildChannelLogoUri(result.channelId).toString();
235                    result.intentAction = Intent.ACTION_VIEW;
236                    result.intentData = buildIntentData(result.channelId);
237                    result.contentType = Programs.CONTENT_ITEM_TYPE;
238                    result.isLive = true;
239                    result.progressPercentage = LocalSearchProvider.PROGRESS_PERCENTAGE_HIDE;
240
241                    searchResults.add(result);
242
243                    if (limit != NO_LIMIT && ++count >= limit) {
244                        break;
245                    }
246                }
247            }
248        }
249        return searchResults;
250    }
251
252    /**
253     * Replaces the channel information - title, description, channel logo - with the current
254     * program information of the channel if the current program information exists and it is not
255     * blocked.
256     */
257    @WorkerThread
258    private void fillProgramInfo(SearchResult result) {
259        long now = System.currentTimeMillis();
260        Uri uri = TvContract.buildProgramsUriForChannel(result.channelId, now, now);
261        String[] projection = new String[] {
262                Programs.COLUMN_TITLE,
263                Programs.COLUMN_POSTER_ART_URI,
264                Programs.COLUMN_CONTENT_RATING,
265                Programs.COLUMN_VIDEO_WIDTH,
266                Programs.COLUMN_VIDEO_HEIGHT,
267                Programs.COLUMN_START_TIME_UTC_MILLIS,
268                Programs.COLUMN_END_TIME_UTC_MILLIS
269        };
270
271        try (Cursor c = mContentResolver.query(uri, projection, null, null, null)) {
272            if (c != null && c.moveToNext() && !isRatingBlocked(c.getString(2))) {
273                String channelName = result.title;
274                long startUtcMillis = c.getLong(5);
275                long endUtcMillis = c.getLong(6);
276                result.title = c.getString(0);
277                result.description = buildProgramDescription(result.channelNumber, channelName,
278                        startUtcMillis, endUtcMillis);
279                String imageUri = c.getString(1);
280                if (imageUri != null) {
281                    result.imageUri = imageUri;
282                }
283                result.videoWidth = c.getInt(3);
284                result.videoHeight = c.getInt(4);
285                result.duration = endUtcMillis - startUtcMillis;
286                result.progressPercentage = getProgressPercentage(startUtcMillis, endUtcMillis);
287            }
288        }
289    }
290
291    private String buildProgramDescription(String channelNumber, String channelName,
292            long programStartUtcMillis, long programEndUtcMillis) {
293        return Utils.getDurationString(mContext, programStartUtcMillis, programEndUtcMillis, false)
294                + System.lineSeparator() + channelNumber + " " + channelName;
295    }
296
297    private int getProgressPercentage(long startUtcMillis, long endUtcMillis) {
298        long current = System.currentTimeMillis();
299        if (startUtcMillis > current || endUtcMillis <= current) {
300            return LocalSearchProvider.PROGRESS_PERCENTAGE_HIDE;
301        }
302        return (int)(100 * (current - startUtcMillis) / (endUtcMillis - startUtcMillis));
303    }
304
305    @WorkerThread
306    private List<SearchResult> searchPrograms(String query, String[] columnForExactMatching,
307            String[] columnForPartialMatching, Set<Long> channelsFound, int limit) {
308        Assert.assertTrue(
309                (columnForExactMatching != null && columnForExactMatching.length > 0) ||
310                (columnForPartialMatching != null && columnForPartialMatching.length > 0));
311
312        String[] projection = {
313                Programs.COLUMN_CHANNEL_ID,
314                Programs.COLUMN_TITLE,
315                Programs.COLUMN_POSTER_ART_URI,
316                Programs.COLUMN_CONTENT_RATING,
317                Programs.COLUMN_VIDEO_WIDTH,
318                Programs.COLUMN_VIDEO_HEIGHT,
319                Programs.COLUMN_START_TIME_UTC_MILLIS,
320                Programs.COLUMN_END_TIME_UTC_MILLIS
321        };
322
323        StringBuilder sb = new StringBuilder();
324        // Search among the programs which are now being on the air.
325        sb.append(Programs.COLUMN_START_TIME_UTC_MILLIS).append("<=? AND ");
326        sb.append(Programs.COLUMN_END_TIME_UTC_MILLIS).append(">=? AND (");
327        appendSelectionString(sb, columnForExactMatching, columnForPartialMatching);
328        sb.append(")");
329        String selection = sb.toString();
330
331        int len = (columnForExactMatching == null ? 0 : columnForExactMatching.length) +
332                (columnForPartialMatching == null ? 0 : columnForPartialMatching.length);
333        String[] selectionArgs = new String[len + 2];
334        selectionArgs[0] = selectionArgs[1] = String.valueOf(System.currentTimeMillis());
335        insertSelectionArgumentStrings(selectionArgs, 2, query, columnForExactMatching,
336                columnForPartialMatching);
337
338        List<SearchResult> searchResults = new ArrayList<>();
339
340        try (Cursor c = mContentResolver.query(Programs.CONTENT_URI, projection, selection,
341                selectionArgs, null)) {
342            if (c != null) {
343                int count = 0;
344                while (c.moveToNext()) {
345                    long id = c.getLong(0);
346                    // Filter out the program whose channel is already searched.
347                    if (channelsFound.contains(id)) {
348                        continue;
349                    }
350                    channelsFound.add(id);
351
352                    // Don't know whether the channel is searchable or not.
353                    String[] channelProjection = {
354                            Channels._ID,
355                            Channels.COLUMN_DISPLAY_NUMBER,
356                            Channels.COLUMN_DISPLAY_NAME
357                    };
358                    sb = new StringBuilder();
359                    sb.append(Channels._ID).append("=? AND ")
360                            .append(Channels.COLUMN_BROWSABLE).append("=1 AND ")
361                            .append(Channels.COLUMN_SEARCHABLE).append("=1");
362                    if (mTvInputManager.isParentalControlsEnabled()) {
363                        sb.append(" AND ").append(Channels.COLUMN_LOCKED).append("=0");
364                    }
365                    String selectionChannel = sb.toString();
366                    try (Cursor cChannel = mContentResolver.query(Channels.CONTENT_URI,
367                            channelProjection, selectionChannel,
368                            new String[] { String.valueOf(id) }, null)) {
369                        if (cChannel != null && cChannel.moveToNext()
370                                && !isRatingBlocked(c.getString(3))) {
371                            long startUtcMillis = c.getLong(6);
372                            long endUtcMillis = c.getLong(7);
373                            SearchResult result = new SearchResult();
374                            result.channelId = c.getLong(0);
375                            result.title = c.getString(1);
376                            result.description = buildProgramDescription(cChannel.getString(1),
377                                    cChannel.getString(2), startUtcMillis, endUtcMillis);
378                            result.imageUri = c.getString(2);
379                            result.intentAction = Intent.ACTION_VIEW;
380                            result.intentData = buildIntentData(id);
381                            result.contentType = Programs.CONTENT_ITEM_TYPE;
382                            result.isLive = true;
383                            result.videoWidth = c.getInt(4);
384                            result.videoHeight = c.getInt(5);
385                            result.duration = endUtcMillis - startUtcMillis;
386                            result.progressPercentage = getProgressPercentage(startUtcMillis,
387                                    endUtcMillis);
388                            searchResults.add(result);
389
390                            if (limit != NO_LIMIT && ++count >= limit) {
391                                break;
392                            }
393                        }
394                    }
395                }
396            }
397        }
398        return searchResults;
399    }
400
401    private String buildIntentData(long channelId) {
402        return TvContract.buildChannelUri(channelId).buildUpon()
403                .appendQueryParameter(Utils.PARAM_SOURCE, SOURCE_TV_SEARCH)
404                .build().toString();
405    }
406
407    private boolean isRatingBlocked(String ratings) {
408        if (TextUtils.isEmpty(ratings) || !mTvInputManager.isParentalControlsEnabled()) {
409            return false;
410        }
411        TvContentRating[] ratingArray = mTvContentRatingCache.getRatings(ratings);
412        if (ratingArray != null) {
413            for (TvContentRating r : ratingArray) {
414                if (mTvInputManager.isRatingBlocked(r)) {
415                    return true;
416                }
417            }
418        }
419        return false;
420    }
421
422    private List<SearchResult> searchInputs(String query, int limit) {
423        if (DEBUG) {
424            Log.d(TAG, "searchInputs(" + query + ", limit=" + limit + ")");
425        }
426
427        query = canonicalizeLabel(query);
428        List<TvInputInfo> inputList = mTvInputManager.getTvInputList();
429        List<SearchResult> results = new ArrayList<>();
430
431        // Find exact matches first.
432        for (TvInputInfo input : inputList) {
433            String label = canonicalizeLabel(input.loadLabel(mContext));
434            String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext));
435            if (TextUtils.equals(query, label) || TextUtils.equals(query, customLabel)) {
436                results.add(buildSearchResultForInput(input.getId()));
437                if (results.size() >= limit) {
438                    return results;
439                }
440            }
441        }
442
443        // Then look for partial matches.
444        for (TvInputInfo input : inputList) {
445            String label = canonicalizeLabel(input.loadLabel(mContext));
446            String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext));
447            if ((label != null && label.contains(query)) ||
448                    (customLabel != null && customLabel.contains(query))) {
449                results.add(buildSearchResultForInput(input.getId()));
450                if (results.size() >= limit) {
451                    return results;
452                }
453            }
454        }
455        return results;
456    }
457
458    private String canonicalizeLabel(CharSequence cs) {
459        Locale locale = mContext.getResources().getConfiguration().locale;
460        return cs != null ? cs.toString().replaceAll("[ -]", "").toLowerCase(locale) : null;
461    }
462
463    private SearchResult buildSearchResultForInput(String inputId) {
464        SearchResult result = new SearchResult();
465        result.intentAction = Intent.ACTION_VIEW;
466        result.intentData = TvContract.buildChannelUriForPassthroughInput(inputId).toString();
467        return result;
468    }
469
470    @WorkerThread
471    private class ChannelComparatorWithSameDisplayNumber implements Comparator<SearchResult> {
472        private final Map<Long, Long> mMaxWatchStartTimeMap = new HashMap<>();
473
474        @Override
475        public int compare(SearchResult lhs, SearchResult rhs) {
476            // Show recently watched channel first
477            Long lhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(lhs.channelId);
478            if (lhsMaxWatchStartTime == null) {
479                lhsMaxWatchStartTime = getMaxWatchStartTime(lhs.channelId);
480                mMaxWatchStartTimeMap.put(lhs.channelId, lhsMaxWatchStartTime);
481            }
482            Long rhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(rhs.channelId);
483            if (rhsMaxWatchStartTime == null) {
484                rhsMaxWatchStartTime = getMaxWatchStartTime(rhs.channelId);
485                mMaxWatchStartTimeMap.put(rhs.channelId, rhsMaxWatchStartTime);
486            }
487            if (!Objects.equals(lhsMaxWatchStartTime, rhsMaxWatchStartTime)) {
488                return Long.compare(rhsMaxWatchStartTime, lhsMaxWatchStartTime);
489            }
490            // Show recently added channel first if there's no watch history.
491            return Long.compare(rhs.channelId, lhs.channelId);
492        }
493
494        private long getMaxWatchStartTime(long channelId) {
495            Uri uri = WatchedPrograms.CONTENT_URI;
496            String[] projections = new String[] {
497                    "MAX(" + WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS
498                    + ") AS max_watch_start_time"
499            };
500            String selection = WatchedPrograms.COLUMN_CHANNEL_ID + "=?";
501            String[] selectionArgs = new String[] { Long.toString(channelId) };
502            try (Cursor c = mContentResolver.query(uri, projections, selection, selectionArgs,
503                    null)) {
504                if (c != null && c.moveToNext()) {
505                    return c.getLong(0);
506                }
507            }
508            return -1;
509        }
510    }
511}
512