Home | History | Annotate | Download | only in search
      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 
     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