• 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.support.annotation.WorkerThread;
32 import android.text.TextUtils;
33 import android.util.Log;
34 
35 import com.android.tv.common.TvContentRatingCache;
36 import com.android.tv.search.LocalSearchProvider.SearchResult;
37 import com.android.tv.util.PermissionUtils;
38 import com.android.tv.util.Utils;
39 
40 import junit.framework.Assert;
41 
42 import java.util.ArrayList;
43 import java.util.Collections;
44 import java.util.Comparator;
45 import java.util.HashMap;
46 import java.util.HashSet;
47 import java.util.List;
48 import java.util.Locale;
49 import java.util.Map;
50 import java.util.Objects;
51 import java.util.Set;
52 
53 /**
54  * An implementation of {@link SearchInterface} to search query from TvProvider directly.
55  */
56 public 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 
TvProviderSearch(Context context)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
search(String query, int limit, int action)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 
appendSelectionString(StringBuilder sb, String[] columnForExactMatching, String[] columnForPartialMatching)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 
insertSelectionArgumentStrings(String[] selectionArgs, int pos, String query, String[] columnForExactMatching, String[] columnForPartialMatching)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
searchChannels(String query, Set<Long> channels, int limit)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
searchChannels(String query, String[] columnForExactMatching, String[] columnForPartialMatching, Set<Long> channelsFound, int limit)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
fillProgramInfo(SearchResult result)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 
buildProgramDescription(String channelNumber, String channelName, long programStartUtcMillis, long programEndUtcMillis)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 
getProgressPercentage(long startUtcMillis, long endUtcMillis)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
searchPrograms(String query, String[] columnForExactMatching, String[] columnForPartialMatching, Set<Long> channelsFound, int limit)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 
buildIntentData(long channelId)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 
isRatingBlocked(String ratings)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 
searchInputs(String query, int limit)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 
canonicalizeLabel(CharSequence cs)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 
buildSearchResultForInput(String inputId)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
compare(SearchResult lhs, SearchResult rhs)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 
getMaxWatchStartTime(long channelId)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