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