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).buildUpon() 406 .appendQueryParameter(Utils.PARAM_SOURCE, SOURCE_TV_SEARCH) 407 .build().toString(); 408 } 409 410 private boolean isRatingBlocked(String ratings) { 411 if (TextUtils.isEmpty(ratings) || !mTvInputManager.isParentalControlsEnabled()) { 412 return false; 413 } 414 TvContentRating[] ratingArray = mTvContentRatingCache.getRatings(ratings); 415 if (ratingArray != null) { 416 for (TvContentRating r : ratingArray) { 417 if (mTvInputManager.isRatingBlocked(r)) { 418 return true; 419 } 420 } 421 } 422 return false; 423 } 424 425 private List<SearchResult> searchInputs(String query, int limit) { 426 if (DEBUG) Log.d(TAG, "Searching inputs: '" + query + "'"); 427 long time = SystemClock.elapsedRealtime(); 428 429 query = canonicalizeLabel(query); 430 List<TvInputInfo> inputList = mTvInputManager.getTvInputList(); 431 List<SearchResult> results = new ArrayList<>(); 432 433 // Find exact matches first. 434 for (TvInputInfo input : inputList) { 435 String label = canonicalizeLabel(input.loadLabel(mContext)); 436 String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext)); 437 if (TextUtils.equals(query, label) || TextUtils.equals(query, customLabel)) { 438 results.add(buildSearchResultForInput(input.getId())); 439 if (results.size() >= limit) { 440 if (DEBUG) { 441 Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for" + 442 " searching inputs: " + (SystemClock.elapsedRealtime() - time) + 443 "(msec)"); 444 } 445 return results; 446 } 447 } 448 } 449 450 // Then look for partial matches. 451 for (TvInputInfo input : inputList) { 452 String label = canonicalizeLabel(input.loadLabel(mContext)); 453 String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext)); 454 if ((label != null && label.contains(query)) || 455 (customLabel != null && customLabel.contains(query))) { 456 results.add(buildSearchResultForInput(input.getId())); 457 if (results.size() >= limit) { 458 if (DEBUG) { 459 Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for" + 460 " searching inputs: " + (SystemClock.elapsedRealtime() - time) + 461 "(msec)"); 462 } 463 return results; 464 } 465 } 466 } 467 if (DEBUG) { 468 Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for searching" + 469 " inputs: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); 470 } 471 return results; 472 } 473 474 private String canonicalizeLabel(CharSequence cs) { 475 Locale locale = mContext.getResources().getConfiguration().locale; 476 return cs != null ? cs.toString().replaceAll("[ -]", "").toLowerCase(locale) : null; 477 } 478 479 private SearchResult buildSearchResultForInput(String inputId) { 480 SearchResult result = new SearchResult(); 481 result.intentAction = Intent.ACTION_VIEW; 482 result.intentData = TvContract.buildChannelUriForPassthroughInput(inputId).toString(); 483 return result; 484 } 485 486 @WorkerThread 487 private class ChannelComparatorWithSameDisplayNumber implements Comparator<SearchResult> { 488 private final Map<Long, Long> mMaxWatchStartTimeMap = new HashMap<>(); 489 490 @Override 491 public int compare(SearchResult lhs, SearchResult rhs) { 492 // Show recently watched channel first 493 Long lhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(lhs.channelId); 494 if (lhsMaxWatchStartTime == null) { 495 lhsMaxWatchStartTime = getMaxWatchStartTime(lhs.channelId); 496 mMaxWatchStartTimeMap.put(lhs.channelId, lhsMaxWatchStartTime); 497 } 498 Long rhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(rhs.channelId); 499 if (rhsMaxWatchStartTime == null) { 500 rhsMaxWatchStartTime = getMaxWatchStartTime(rhs.channelId); 501 mMaxWatchStartTimeMap.put(rhs.channelId, rhsMaxWatchStartTime); 502 } 503 if (!Objects.equals(lhsMaxWatchStartTime, rhsMaxWatchStartTime)) { 504 return Long.compare(rhsMaxWatchStartTime, lhsMaxWatchStartTime); 505 } 506 // Show recently added channel first if there's no watch history. 507 return Long.compare(rhs.channelId, lhs.channelId); 508 } 509 510 private long getMaxWatchStartTime(long channelId) { 511 Uri uri = WatchedPrograms.CONTENT_URI; 512 String[] projections = new String[] { 513 "MAX(" + WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS 514 + ") AS max_watch_start_time" 515 }; 516 String selection = WatchedPrograms.COLUMN_CHANNEL_ID + "=?"; 517 String[] selectionArgs = new String[] { Long.toString(channelId) }; 518 try (Cursor c = mContentResolver.query(uri, projections, selection, selectionArgs, 519 null)) { 520 if (c != null && c.moveToNext()) { 521 return c.getLong(0); 522 } 523 } 524 return -1; 525 } 526 } 527 } 528