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