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