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.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 
     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
     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 
    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 
    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
    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
    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
    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 
    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 
    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
    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 
    404     private String buildIntentData(long channelId) {
    405         return TvContract.buildChannelUri(channelId).toString();
    406     }
    407 
    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 
    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 
    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 
    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
    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 
    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