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 import com.android.tv.common.TvContentRatingCache; 36 import com.android.tv.common.util.PermissionUtils; 37 import com.android.tv.search.LocalSearchProvider.SearchResult; 38 import com.android.tv.util.Utils; 39 import com.google.common.collect.ImmutableList; 40 import java.util.ArrayList; 41 import java.util.Collections; 42 import java.util.Comparator; 43 import java.util.HashMap; 44 import java.util.HashSet; 45 import java.util.List; 46 import java.util.Locale; 47 import java.util.Map; 48 import java.util.Objects; 49 import java.util.Set; 50 import java.util.concurrent.TimeUnit; 51 52 /** An implementation of {@link SearchInterface} to search query from TvProvider directly. */ 53 public class TvProviderSearch implements SearchInterface { 54 private static final String TAG = "TvProviderSearch"; 55 private static final boolean DEBUG = false; 56 57 private static final long SEARCH_TIME_FRAME_MS = TimeUnit.DAYS.toMillis(14); 58 59 private static final int NO_LIMIT = 0; 60 61 private final Context mContext; 62 private final ContentResolver mContentResolver; 63 private final TvInputManager mTvInputManager; 64 private final TvContentRatingCache mTvContentRatingCache = TvContentRatingCache.getInstance(); 65 TvProviderSearch(Context context)66 TvProviderSearch(Context context) { 67 mContext = context; 68 mContentResolver = context.getContentResolver(); 69 mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); 70 } 71 72 /** 73 * Search channels, inputs, or programs from TvProvider. This assumes that parental control 74 * settings will not be change while searching. 75 * 76 * @param action One of {@link #ACTION_TYPE_SWITCH_CHANNEL}, {@link #ACTION_TYPE_SWITCH_INPUT}, 77 * or {@link #ACTION_TYPE_AMBIGUOUS}, 78 */ 79 @Override 80 @WorkerThread search(String query, int limit, int action)81 public List<SearchResult> search(String query, int limit, int action) { 82 // TODO(b/72499463): add a test. 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( 112 searchPrograms( 113 query, 114 null, 115 new String[] {Programs.COLUMN_TITLE, Programs.COLUMN_SHORT_DESCRIPTION}, 116 channelsFound, 117 limit)); 118 } 119 return results; 120 } 121 appendSelectionString( StringBuilder sb, String[] columnForExactMatching, String[] columnForPartialMatching)122 private void appendSelectionString( 123 StringBuilder sb, String[] columnForExactMatching, String[] columnForPartialMatching) { 124 boolean firstColumn = true; 125 if (columnForExactMatching != null) { 126 for (String column : columnForExactMatching) { 127 if (!firstColumn) { 128 sb.append(" OR "); 129 } else { 130 firstColumn = false; 131 } 132 sb.append(column).append("=?"); 133 } 134 } 135 if (columnForPartialMatching != null) { 136 for (String column : columnForPartialMatching) { 137 if (!firstColumn) { 138 sb.append(" OR "); 139 } else { 140 firstColumn = false; 141 } 142 sb.append(column).append(" LIKE ?"); 143 } 144 } 145 } 146 insertSelectionArgumentStrings( String[] selectionArgs, int pos, String query, String[] columnForExactMatching, String[] columnForPartialMatching)147 private void insertSelectionArgumentStrings( 148 String[] selectionArgs, 149 int pos, 150 String query, 151 String[] columnForExactMatching, 152 String[] columnForPartialMatching) { 153 if (columnForExactMatching != null) { 154 int until = pos + columnForExactMatching.length; 155 for (; pos < until; ++pos) { 156 selectionArgs[pos] = query; 157 } 158 } 159 String selectionArg = "%" + query + "%"; 160 if (columnForPartialMatching != null) { 161 int until = pos + columnForPartialMatching.length; 162 for (; pos < until; ++pos) { 163 selectionArgs[pos] = selectionArg; 164 } 165 } 166 } 167 168 @WorkerThread searchChannels(String query, Set<Long> channels, int limit)169 private List<SearchResult> searchChannels(String query, Set<Long> channels, int limit) { 170 if (DEBUG) Log.d(TAG, "Searching channels: '" + query + "'"); 171 long time = SystemClock.elapsedRealtime(); 172 List<SearchResult> results = new ArrayList<>(); 173 if (TextUtils.isDigitsOnly(query)) { 174 results.addAll( 175 searchChannels( 176 query, 177 new String[] {Channels.COLUMN_DISPLAY_NUMBER}, 178 null, 179 channels, 180 NO_LIMIT)); 181 if (results.size() > 1) { 182 Collections.sort(results, new ChannelComparatorWithSameDisplayNumber()); 183 } 184 } 185 if (results.size() < limit) { 186 results.addAll( 187 searchChannels( 188 query, 189 null, 190 new String[] { 191 Channels.COLUMN_DISPLAY_NAME, Channels.COLUMN_DESCRIPTION 192 }, 193 channels, 194 limit - results.size())); 195 } 196 if (results.size() > limit) { 197 results = results.subList(0, limit); 198 } 199 for (int i = 0; i < results.size(); i++) { 200 results.set(i, fillProgramInfo(results.get(i))); 201 } 202 if (DEBUG) { 203 Log.d( 204 TAG, 205 "Found " 206 + results.size() 207 + " channels. Elapsed time for searching" 208 + " channels: " 209 + (SystemClock.elapsedRealtime() - time) 210 + "(msec)"); 211 } 212 return results; 213 } 214 215 @WorkerThread searchChannels( String query, String[] columnForExactMatching, String[] columnForPartialMatching, Set<Long> channelsFound, int limit)216 private List<SearchResult> searchChannels( 217 String query, 218 String[] columnForExactMatching, 219 String[] columnForPartialMatching, 220 Set<Long> channelsFound, 221 int limit) { 222 String[] projection = { 223 Channels._ID, 224 Channels.COLUMN_DISPLAY_NUMBER, 225 Channels.COLUMN_DISPLAY_NAME, 226 Channels.COLUMN_DESCRIPTION 227 }; 228 229 StringBuilder sb = new StringBuilder(); 230 sb.append(Channels.COLUMN_BROWSABLE) 231 .append("=1 AND ") 232 .append(Channels.COLUMN_SEARCHABLE) 233 .append("=1"); 234 if (mTvInputManager.isParentalControlsEnabled()) { 235 sb.append(" AND ").append(Channels.COLUMN_LOCKED).append("=0"); 236 } 237 sb.append(" AND ("); 238 appendSelectionString(sb, columnForExactMatching, columnForPartialMatching); 239 sb.append(")"); 240 String selection = sb.toString(); 241 242 int len = 243 (columnForExactMatching == null ? 0 : columnForExactMatching.length) 244 + (columnForPartialMatching == null ? 0 : columnForPartialMatching.length); 245 String[] selectionArgs = new String[len]; 246 insertSelectionArgumentStrings( 247 selectionArgs, 0, query, columnForExactMatching, columnForPartialMatching); 248 249 List<SearchResult> searchResults = new ArrayList<>(); 250 251 try (Cursor c = 252 mContentResolver.query( 253 Channels.CONTENT_URI, projection, selection, selectionArgs, null)) { 254 if (c != null) { 255 int count = 0; 256 while (c.moveToNext()) { 257 long id = c.getLong(0); 258 // Filter out the channel which has been already searched. 259 if (channelsFound.contains(id)) { 260 continue; 261 } 262 channelsFound.add(id); 263 264 SearchResult.Builder result = SearchResult.builder(); 265 result.setChannelId(id); 266 result.setChannelNumber(c.getString(1)); 267 result.setTitle(c.getString(2)); 268 result.setDescription(c.getString(3)); 269 result.setImageUri(TvContract.buildChannelLogoUri(id).toString()); 270 result.setIntentAction(Intent.ACTION_VIEW); 271 result.setIntentData(buildIntentData(id)); 272 result.setContentType(Programs.CONTENT_ITEM_TYPE); 273 result.setIsLive(true); 274 result.setProgressPercentage(SearchInterface.PROGRESS_PERCENTAGE_HIDE); 275 276 searchResults.add(result.build()); 277 278 if (limit != NO_LIMIT && ++count >= limit) { 279 break; 280 } 281 } 282 } 283 } 284 return searchResults; 285 } 286 287 /** 288 * Replaces the channel information - title, description, channel logo - with the current 289 * program information of the channel if the current program information exists and it is not 290 * blocked. 291 */ 292 @WorkerThread fillProgramInfo(SearchResult result)293 private SearchResult fillProgramInfo(SearchResult result) { 294 long now = System.currentTimeMillis(); 295 Uri uri = TvContract.buildProgramsUriForChannel(result.getChannelId(), now, now); 296 String[] projection = 297 new String[] { 298 Programs.COLUMN_TITLE, 299 Programs.COLUMN_POSTER_ART_URI, 300 Programs.COLUMN_CONTENT_RATING, 301 Programs.COLUMN_VIDEO_WIDTH, 302 Programs.COLUMN_VIDEO_HEIGHT, 303 Programs.COLUMN_START_TIME_UTC_MILLIS, 304 Programs.COLUMN_END_TIME_UTC_MILLIS 305 }; 306 307 try (Cursor c = mContentResolver.query(uri, projection, null, null, null)) { 308 if (c != null && c.moveToNext() && !isRatingBlocked(c.getString(2))) { 309 String channelName = result.getTitle(); 310 String channelNumber = result.getChannelNumber(); 311 SearchResult.Builder builder = SearchResult.builder(); 312 long startUtcMillis = c.getLong(5); 313 long endUtcMillis = c.getLong(6); 314 builder.setTitle(c.getString(0)); 315 builder.setDescription( 316 buildProgramDescription( 317 channelNumber, channelName, startUtcMillis, endUtcMillis)); 318 String imageUri = c.getString(1); 319 if (imageUri != null) { 320 builder.setImageUri(imageUri); 321 } 322 builder.setVideoWidth(c.getInt(3)); 323 builder.setVideoHeight(c.getInt(4)); 324 builder.setDuration(endUtcMillis - startUtcMillis); 325 builder.setProgressPercentage(getProgressPercentage(startUtcMillis, endUtcMillis)); 326 return builder.build(); 327 } 328 } 329 return result; 330 } 331 buildProgramDescription( String channelNumber, String channelName, long programStartUtcMillis, long programEndUtcMillis)332 private String buildProgramDescription( 333 String channelNumber, 334 String channelName, 335 long programStartUtcMillis, 336 long programEndUtcMillis) { 337 return Utils.getDurationString(mContext, programStartUtcMillis, programEndUtcMillis, false) 338 + System.lineSeparator() 339 + channelNumber 340 + " " 341 + channelName; 342 } 343 getProgressPercentage(long startUtcMillis, long endUtcMillis)344 private int getProgressPercentage(long startUtcMillis, long endUtcMillis) { 345 long current = System.currentTimeMillis(); 346 if (startUtcMillis > current || endUtcMillis <= current) { 347 return SearchInterface.PROGRESS_PERCENTAGE_HIDE; 348 } 349 return (int) (100 * (current - startUtcMillis) / (endUtcMillis - startUtcMillis)); 350 } 351 352 @WorkerThread searchPrograms( String query, String[] columnForExactMatching, String[] columnForPartialMatching, Set<Long> channelsFound, int limit)353 private List<SearchResult> searchPrograms( 354 String query, 355 String[] columnForExactMatching, 356 String[] columnForPartialMatching, 357 Set<Long> channelsFound, 358 int limit) { 359 if (DEBUG) Log.d(TAG, "Searching programs: '" + query + "'"); 360 long time = SystemClock.elapsedRealtime(); 361 String[] projection = { 362 Programs.COLUMN_CHANNEL_ID, 363 Programs.COLUMN_TITLE, 364 Programs.COLUMN_POSTER_ART_URI, 365 Programs.COLUMN_CONTENT_RATING, 366 Programs.COLUMN_VIDEO_WIDTH, 367 Programs.COLUMN_VIDEO_HEIGHT, 368 Programs.COLUMN_START_TIME_UTC_MILLIS, 369 Programs.COLUMN_END_TIME_UTC_MILLIS, 370 Programs._ID 371 }; 372 373 StringBuilder sb = new StringBuilder(); 374 // Search among the programs which are now being on the air. 375 sb.append(Programs.COLUMN_START_TIME_UTC_MILLIS).append("<=? AND "); 376 sb.append(Programs.COLUMN_END_TIME_UTC_MILLIS).append(">=? AND ("); 377 appendSelectionString(sb, columnForExactMatching, columnForPartialMatching); 378 sb.append(")"); 379 String selection = sb.toString(); 380 381 int len = 382 (columnForExactMatching == null ? 0 : columnForExactMatching.length) 383 + (columnForPartialMatching == null ? 0 : columnForPartialMatching.length); 384 String[] selectionArgs = new String[len + 2]; 385 long now = System.currentTimeMillis(); 386 selectionArgs[0] = String.valueOf(now + SEARCH_TIME_FRAME_MS); 387 selectionArgs[1] = String.valueOf(now); 388 insertSelectionArgumentStrings( 389 selectionArgs, 2, query, columnForExactMatching, columnForPartialMatching); 390 391 List<SearchResult> searchResults = new ArrayList<>(); 392 393 try (Cursor c = 394 mContentResolver.query( 395 Programs.CONTENT_URI, projection, selection, selectionArgs, null)) { 396 if (c != null) { 397 int count = 0; 398 while (c.moveToNext()) { 399 long id = c.getLong(0); 400 // Filter out the program whose channel is already searched. 401 if (channelsFound.contains(id)) { 402 continue; 403 } 404 channelsFound.add(id); 405 406 // Don't know whether the channel is searchable or not. 407 String[] channelProjection = { 408 Channels._ID, Channels.COLUMN_DISPLAY_NUMBER, Channels.COLUMN_DISPLAY_NAME 409 }; 410 sb = new StringBuilder(); 411 sb.append(Channels._ID) 412 .append("=? AND ") 413 .append(Channels.COLUMN_BROWSABLE) 414 .append("=1 AND ") 415 .append(Channels.COLUMN_SEARCHABLE) 416 .append("=1"); 417 if (mTvInputManager.isParentalControlsEnabled()) { 418 sb.append(" AND ").append(Channels.COLUMN_LOCKED).append("=0"); 419 } 420 String selectionChannel = sb.toString(); 421 try (Cursor cChannel = 422 mContentResolver.query( 423 Channels.CONTENT_URI, 424 channelProjection, 425 selectionChannel, 426 new String[] {String.valueOf(id)}, 427 null)) { 428 if (cChannel != null 429 && cChannel.moveToNext() 430 && !isRatingBlocked(c.getString(3))) { 431 long startUtcMillis = c.getLong(6); 432 long endUtcMillis = c.getLong(7); 433 SearchResult.Builder result = SearchResult.builder(); 434 result.setChannelId(c.getLong(0)); 435 result.setTitle(c.getString(1)); 436 result.setDescription( 437 buildProgramDescription( 438 cChannel.getString(1), 439 cChannel.getString(2), 440 startUtcMillis, 441 endUtcMillis)); 442 result.setImageUri(c.getString(2)); 443 result.setIntentAction(Intent.ACTION_VIEW); 444 result.setIntentData(buildIntentData(id)); 445 result.setIntentExtraData( 446 TvContract.buildProgramUri(c.getLong(8)).toString()); 447 result.setContentType(Programs.CONTENT_ITEM_TYPE); 448 result.setIsLive(true); 449 result.setVideoWidth(c.getInt(4)); 450 result.setVideoHeight(c.getInt(5)); 451 result.setDuration(endUtcMillis - startUtcMillis); 452 result.setProgressPercentage( 453 getProgressPercentage(startUtcMillis, endUtcMillis)); 454 searchResults.add(result.build()); 455 456 if (limit != NO_LIMIT && ++count >= limit) { 457 break; 458 } 459 } 460 } 461 } 462 } 463 } 464 if (DEBUG) { 465 Log.d( 466 TAG, 467 "Found " 468 + searchResults.size() 469 + " programs. Elapsed time for searching" 470 + " programs: " 471 + (SystemClock.elapsedRealtime() - time) 472 + "(msec)"); 473 } 474 return searchResults; 475 } 476 buildIntentData(long channelId)477 private String buildIntentData(long channelId) { 478 return TvContract.buildChannelUri(channelId).toString(); 479 } 480 isRatingBlocked(String ratings)481 private boolean isRatingBlocked(String ratings) { 482 if (TextUtils.isEmpty(ratings) || !mTvInputManager.isParentalControlsEnabled()) { 483 return false; 484 } 485 ImmutableList<TvContentRating> ratingArray = mTvContentRatingCache.getRatings(ratings); 486 if (ratingArray != null) { 487 for (TvContentRating r : ratingArray) { 488 if (mTvInputManager.isRatingBlocked(r)) { 489 return true; 490 } 491 } 492 } 493 return false; 494 } 495 searchInputs(String query, int limit)496 private List<SearchResult> searchInputs(String query, int limit) { 497 if (DEBUG) Log.d(TAG, "Searching inputs: '" + query + "'"); 498 long time = SystemClock.elapsedRealtime(); 499 500 query = canonicalizeLabel(query); 501 List<TvInputInfo> inputList = mTvInputManager.getTvInputList(); 502 List<SearchResult> results = new ArrayList<>(); 503 504 // Find exact matches first. 505 for (TvInputInfo input : inputList) { 506 if (input.getType() == TvInputInfo.TYPE_TUNER) { 507 continue; 508 } 509 String label = canonicalizeLabel(input.loadLabel(mContext)); 510 String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext)); 511 if (TextUtils.equals(query, label) || TextUtils.equals(query, customLabel)) { 512 results.add(buildSearchResultForInput(input.getId())); 513 if (results.size() >= limit) { 514 if (DEBUG) { 515 Log.d( 516 TAG, 517 "Found " 518 + results.size() 519 + " inputs. Elapsed time for" 520 + " searching inputs: " 521 + (SystemClock.elapsedRealtime() - time) 522 + "(msec)"); 523 } 524 return results; 525 } 526 } 527 } 528 529 // Then look for partial matches. 530 for (TvInputInfo input : inputList) { 531 if (input.getType() == TvInputInfo.TYPE_TUNER) { 532 continue; 533 } 534 String label = canonicalizeLabel(input.loadLabel(mContext)); 535 String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext)); 536 if ((label != null && label.contains(query)) 537 || (customLabel != null && customLabel.contains(query))) { 538 results.add(buildSearchResultForInput(input.getId())); 539 if (results.size() >= limit) { 540 if (DEBUG) { 541 Log.d( 542 TAG, 543 "Found " 544 + results.size() 545 + " inputs. Elapsed time for" 546 + " searching inputs: " 547 + (SystemClock.elapsedRealtime() - time) 548 + "(msec)"); 549 } 550 return results; 551 } 552 } 553 } 554 if (DEBUG) { 555 Log.d( 556 TAG, 557 "Found " 558 + results.size() 559 + " inputs. Elapsed time for searching" 560 + " inputs: " 561 + (SystemClock.elapsedRealtime() - time) 562 + "(msec)"); 563 } 564 return results; 565 } 566 canonicalizeLabel(CharSequence cs)567 private String canonicalizeLabel(CharSequence cs) { 568 Locale locale = mContext.getResources().getConfiguration().locale; 569 return cs != null ? cs.toString().replaceAll("[ -]", "").toLowerCase(locale) : null; 570 } 571 buildSearchResultForInput(String inputId)572 private SearchResult buildSearchResultForInput(String inputId) { 573 SearchResult.Builder result = SearchResult.builder(); 574 result.setIntentAction(Intent.ACTION_VIEW); 575 result.setIntentData(TvContract.buildChannelUriForPassthroughInput(inputId).toString()); 576 return result.build(); 577 } 578 579 @WorkerThread 580 private class ChannelComparatorWithSameDisplayNumber implements Comparator<SearchResult> { 581 private final Map<Long, Long> mMaxWatchStartTimeMap = new HashMap<>(); 582 583 @Override compare(SearchResult lhs, SearchResult rhs)584 public int compare(SearchResult lhs, SearchResult rhs) { 585 // Show recently watched channel first 586 Long lhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(lhs.getChannelId()); 587 if (lhsMaxWatchStartTime == null) { 588 lhsMaxWatchStartTime = getMaxWatchStartTime(lhs.getChannelId()); 589 mMaxWatchStartTimeMap.put(lhs.getChannelId(), lhsMaxWatchStartTime); 590 } 591 Long rhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(rhs.getChannelId()); 592 if (rhsMaxWatchStartTime == null) { 593 rhsMaxWatchStartTime = getMaxWatchStartTime(rhs.getChannelId()); 594 mMaxWatchStartTimeMap.put(rhs.getChannelId(), rhsMaxWatchStartTime); 595 } 596 if (!Objects.equals(lhsMaxWatchStartTime, rhsMaxWatchStartTime)) { 597 return Long.compare(rhsMaxWatchStartTime, lhsMaxWatchStartTime); 598 } 599 // Show recently added channel first if there's no watch history. 600 return Long.compare(rhs.getChannelId(), lhs.getChannelId()); 601 } 602 getMaxWatchStartTime(long channelId)603 private long getMaxWatchStartTime(long channelId) { 604 Uri uri = WatchedPrograms.CONTENT_URI; 605 String[] projections = 606 new String[] { 607 "MAX(" 608 + WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS 609 + ") AS max_watch_start_time" 610 }; 611 String selection = WatchedPrograms.COLUMN_CHANNEL_ID + "=?"; 612 String[] selectionArgs = new String[] {Long.toString(channelId)}; 613 try (Cursor c = 614 mContentResolver.query(uri, projections, selection, selectionArgs, null)) { 615 if (c != null && c.moveToNext()) { 616 return c.getLong(0); 617 } 618 } 619 return -1; 620 } 621 } 622 } 623