diff options
Diffstat (limited to 'src/com/android/tv/search/TvProviderSearch.java')
-rw-r--r-- | src/com/android/tv/search/TvProviderSearch.java | 535 |
1 files changed, 415 insertions, 120 deletions
diff --git a/src/com/android/tv/search/TvProviderSearch.java b/src/com/android/tv/search/TvProviderSearch.java index 4d6f53f6..2548d34a 100644 --- a/src/com/android/tv/search/TvProviderSearch.java +++ b/src/com/android/tv/search/TvProviderSearch.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014 The Android Open Source Project + * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,192 +16,487 @@ package com.android.tv.search; -import android.content.ContentUris; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.database.Cursor; +import android.media.tv.TvContentRating; +import android.media.tv.TvContract; import android.media.tv.TvContract.Channels; import android.media.tv.TvContract.Programs; +import android.media.tv.TvContract.WatchedPrograms; +import android.media.tv.TvInputInfo; +import android.media.tv.TvInputManager; import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; -import com.android.internal.util.Preconditions; +import com.android.tv.search.LocalSearchProvider.SearchResult; +import com.android.tv.util.Utils; + +import junit.framework.Assert; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; public class TvProviderSearch { - public static List<SearchResult> search(Context context, String query, int limit) { - List<SearchResult> results = new ArrayList<SearchResult>(); - results.addAll(searchChannels(context, query, new String[] { - Channels.COLUMN_DISPLAY_NAME, - Channels.COLUMN_DESCRIPTION - }, limit)); - if (results.size() >= limit) { - return results; - } + private static final boolean DEBUG = false; + private static final String TAG = "TvProviderSearch"; - Set<Long> previousResults = getChannelIdSet(results); - limit -= results.size(); - results.addAll(searchPrograms(context, query, new String[] { - Programs.COLUMN_TITLE, - Programs.COLUMN_SHORT_DESCRIPTION - }, previousResults, limit)); + private static final int NO_LIMIT = 0; + + static final int ACTION_TYPE_AMBIGUOUS = 1; + static final int ACTION_TYPE_SWITCH_CHANNEL = 2; + static final int ACTION_TYPE_SWITCH_INPUT = 3; + + private static final String SOURCE_TV_SEARCH = "TvSearch"; + + private final Context mContext; + private final ContentResolver mContentResolver; + private final TvInputManager mTvInputManager; + + TvProviderSearch(Context context) { + mContext = context; + mContentResolver = context.getContentResolver(); + mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); + } + + /** + * Search channels, inputs, or programs from TvProvider. + * This assumes that parental control settings will not be change while searching. + * + * @param action One of {@link #ACTION_TYPE_SWITCH_CHANNEL}, {@link #ACTION_TYPE_SWITCH_INPUT}, + * or {@link #ACTION_TYPE_AMBIGUOUS}, + */ + public List<SearchResult> search(String query, int limit, int action) { + List<SearchResult> results = new ArrayList<>(); + Set<Long> channelsFound = new HashSet<>(); + if (action == ACTION_TYPE_SWITCH_CHANNEL) { + results.addAll(searchChannels(query, channelsFound, limit)); + } else if (action == ACTION_TYPE_SWITCH_INPUT) { + results.addAll(searchInputs(query, limit)); + } else { + // Search channels first. + results.addAll(searchChannels(query, channelsFound, limit)); + if (results.size() >= limit) { + return results; + } + + // In case the user wanted to perform the action "switch to XXX", which is indicated by + // setting the limit to 1, search inputs. + if (limit == 1) { + results.addAll(searchInputs(query, limit)); + if (!results.isEmpty()) { + return results; + } + } + + // Lastly, search programs. + limit -= results.size(); + results.addAll(searchPrograms(query, null, new String[] { + Programs.COLUMN_TITLE, Programs.COLUMN_SHORT_DESCRIPTION }, + channelsFound, limit)); + } return results; } - private static Set<Long> getChannelIdSet(List<SearchResult> results) { - Set<Long> channelIdSet = new HashSet<Long>(); - for (SearchResult sr : results) { - channelIdSet.add(sr.getChannelId()); + private StringBuilder appendSelectionString(StringBuilder sb, + String[] columnForExactMatching, String[] columnForPartialMatching) { + boolean firstColumn = true; + if (columnForExactMatching != null) { + for (String column : columnForExactMatching) { + if (!firstColumn) { + sb.append(" OR "); + } else { + firstColumn = false; + } + sb.append(column).append("=?"); + } + } + if (columnForPartialMatching != null) { + for (String column : columnForPartialMatching) { + if (!firstColumn) { + sb.append(" OR "); + } else { + firstColumn = false; + } + sb.append(column).append(" LIKE ?"); + } + } + return sb; + } + + private void insertSelectionArgumentStrings(String[] selectionArgs, int pos, + String query, String[] columnForExactMatching, String[] columnForPartialMatching) { + if (columnForExactMatching != null) { + int until = pos + columnForExactMatching.length; + for (; pos < until; ++pos) { + selectionArgs[pos] = query; + } + } + String selectionArg = "%" + query + "%"; + if (columnForPartialMatching != null) { + int until = pos + columnForPartialMatching.length; + for (; pos < until; ++pos) { + selectionArgs[pos] = selectionArg; + } + } + } + + private List<SearchResult> searchChannels(String query, Set<Long> channels, int limit) { + List<SearchResult> results = new ArrayList<>(); + if (TextUtils.isDigitsOnly(query)) { + results.addAll(searchChannels(query, new String[] { Channels.COLUMN_DISPLAY_NUMBER }, + null, channels, NO_LIMIT)); + if (results.size() > 1) { + Collections.sort(results, new ChannelComparatorWithSameDisplayNumber()); + } + } + if (results.size() < limit) { + results.addAll(searchChannels(query, null, + new String[] { Channels.COLUMN_DISPLAY_NAME, Channels.COLUMN_DESCRIPTION }, + channels, limit - results.size())); } - return channelIdSet; + if (results.size() > limit) { + results = results.subList(0, limit); + } + for (SearchResult result : results) { + fillProgramInfo(result); + } + return results; } - private static List<SearchResult> searchChannels(Context context, String query, - String[] columnNames, int limit) { - Preconditions.checkState(columnNames != null && columnNames.length > 0); + private List<SearchResult> searchChannels(String query, String[] columnForExactMatching, + String[] columnForPartialMatching, Set<Long> channelsFound, int limit) { + Assert.assertTrue( + (columnForExactMatching != null && columnForExactMatching.length > 0) || + (columnForPartialMatching != null && columnForPartialMatching.length > 0)); String[] projection = { Channels._ID, + Channels.COLUMN_DISPLAY_NUMBER, Channels.COLUMN_DISPLAY_NAME, - Channels.COLUMN_DESCRIPTION, + Channels.COLUMN_DESCRIPTION }; StringBuilder sb = new StringBuilder(); - sb.append(Channels.COLUMN_BROWSABLE).append("=1 AND "); - sb.append(Channels.COLUMN_SEARCHABLE).append("=1 AND ("); - sb.append(columnNames[0]).append(" like ?"); - for (int i = 1; i < columnNames.length; ++i) { - sb.append(" OR ").append(columnNames[i]).append(" like ?"); + sb.append(Channels.COLUMN_BROWSABLE).append("=1 AND ") + .append(Channels.COLUMN_SEARCHABLE).append("=1"); + if (mTvInputManager.isParentalControlsEnabled()) { + sb.append(" AND ").append(Channels.COLUMN_LOCKED).append("=0"); } + sb.append(" AND ("); + appendSelectionString(sb, columnForExactMatching, columnForPartialMatching); sb.append(")"); String selection = sb.toString(); - String selectionArg = "%" + query + "%"; - String[] selectionArgs = new String[columnNames.length]; - for (int i = 0; i < selectionArgs.length; ++i) { - selectionArgs[i] = selectionArg; + int len = (columnForExactMatching == null ? 0 : columnForExactMatching.length) + + (columnForPartialMatching == null ? 0 : columnForPartialMatching.length); + String[] selectionArgs = new String[len]; + insertSelectionArgumentStrings(selectionArgs, 0, query, columnForExactMatching, + columnForPartialMatching); + + List<SearchResult> searchResults = new ArrayList<>(); + + try (Cursor c = mContentResolver.query(Channels.CONTENT_URI, projection, selection, + selectionArgs, null)) { + if (c != null) { + int count = 0; + while (c.moveToNext()) { + long id = c.getLong(0); + // Filter out the channel which has been already searched. + if (channelsFound.contains(id)) { + continue; + } + channelsFound.add(id); + + SearchResult result = new SearchResult(); + result.channelId = id; + result.channelNumber = c.getString(1); + result.title = c.getString(2); + result.description = c.getString(3); + result.imageUri = TvContract.buildChannelLogoUri(result.channelId).toString(); + result.intentAction = Intent.ACTION_VIEW; + result.intentData = buildIntentData(result.channelId); + result.contentType = Programs.CONTENT_ITEM_TYPE; + result.isLive = true; + result.progressPercentage = LocalSearchProvider.PROGRESS_PERCENTAGE_HIDE; + + searchResults.add(result); + + if (limit != NO_LIMIT && ++count >= limit) { + break; + } + } + } + } + return searchResults; + } + + /** + * Replaces the channel information - title, description, channel logo - with the current + * program information of the channel if the current program information exists and it is not + * blocked. + */ + private void fillProgramInfo(SearchResult result) { + long now = System.currentTimeMillis(); + Uri uri = TvContract.buildProgramsUriForChannel(result.channelId, now, now); + String[] projection = new String[] { + Programs.COLUMN_TITLE, + Programs.COLUMN_POSTER_ART_URI, + Programs.COLUMN_CONTENT_RATING, + Programs.COLUMN_VIDEO_WIDTH, + Programs.COLUMN_VIDEO_HEIGHT, + Programs.COLUMN_START_TIME_UTC_MILLIS, + Programs.COLUMN_END_TIME_UTC_MILLIS + }; + + try (Cursor c = mContentResolver.query(uri, projection, null, null, null)) { + if (c != null && c.moveToNext() && !isRatingBlocked(c.getString(2))) { + String channelName = result.title; + long startUtcMillis = c.getLong(5); + long endUtcMillis = c.getLong(6); + result.title = c.getString(0); + result.description = buildProgramDescription(result.channelNumber, channelName, + startUtcMillis, endUtcMillis); + String imageUri = c.getString(1); + if (imageUri != null) { + result.imageUri = imageUri; + } + result.videoWidth = c.getInt(3); + result.videoHeight = c.getInt(4); + result.duration = endUtcMillis - startUtcMillis; + result.progressPercentage = getProgressPercentage(startUtcMillis, endUtcMillis); + } } + } + + private String buildProgramDescription(String channelNumber, String channelName, + long programStartUtcMillis, long programEndUtcMillis) { + return Utils.getDurationString(mContext, programStartUtcMillis, programEndUtcMillis, false) + + System.lineSeparator() + channelNumber + " " + channelName; + } - return search(context, Channels.CONTENT_URI, projection, selection, selectionArgs, limit, - null); + private int getProgressPercentage(long startUtcMillis, long endUtcMillis) { + long current = System.currentTimeMillis(); + if (startUtcMillis > current || endUtcMillis <= current) { + return LocalSearchProvider.PROGRESS_PERCENTAGE_HIDE; + } + return (int)(100 * (current - startUtcMillis) / (endUtcMillis - startUtcMillis)); } - private static List<SearchResult> searchPrograms(final Context context, String query, - String[] columnNames, final Set<Long> previousResults, int limit) { - Preconditions.checkState(columnNames != null && columnNames.length > 0); + private List<SearchResult> searchPrograms(String query, String[] columnForExactMatching, + String[] columnForPartialMatching, Set<Long> channelsFound, int limit) { + Assert.assertTrue( + (columnForExactMatching != null && columnForExactMatching.length > 0) || + (columnForPartialMatching != null && columnForPartialMatching.length > 0)); String[] projection = { Programs.COLUMN_CHANNEL_ID, Programs.COLUMN_TITLE, - Programs.COLUMN_SHORT_DESCRIPTION, + Programs.COLUMN_POSTER_ART_URI, + Programs.COLUMN_CONTENT_RATING, + Programs.COLUMN_VIDEO_WIDTH, + Programs.COLUMN_VIDEO_HEIGHT, + Programs.COLUMN_START_TIME_UTC_MILLIS, + Programs.COLUMN_END_TIME_UTC_MILLIS }; StringBuilder sb = new StringBuilder(); // Search among the programs which are now being on the air. sb.append(Programs.COLUMN_START_TIME_UTC_MILLIS).append("<=? AND "); sb.append(Programs.COLUMN_END_TIME_UTC_MILLIS).append(">=? AND ("); - sb.append(columnNames[0]).append(" like ?"); - for (int i = 1; i < columnNames.length; ++i) { - sb.append(" OR ").append(columnNames[0]).append(" like ?"); - } + appendSelectionString(sb, columnForExactMatching, columnForPartialMatching); sb.append(")"); String selection = sb.toString(); - String selectionArg = "%" + query + "%"; - String[] selectionArgs = new String[columnNames.length+2]; + + int len = (columnForExactMatching == null ? 0 : columnForExactMatching.length) + + (columnForPartialMatching == null ? 0 : columnForPartialMatching.length); + String[] selectionArgs = new String[len + 2]; selectionArgs[0] = selectionArgs[1] = String.valueOf(System.currentTimeMillis()); - for (int i = 2; i < selectionArgs.length; ++i) { - selectionArgs[i] = selectionArg; - } + insertSelectionArgumentStrings(selectionArgs, 2, query, columnForExactMatching, + columnForPartialMatching); - return search(context, Programs.CONTENT_URI, projection, selection, selectionArgs, limit, - new ResultFilter() { - private Map<Long, Boolean> searchableMap = new HashMap<Long, Boolean>(); + List<SearchResult> searchResults = new ArrayList<>(); - @Override - public boolean filter(Cursor c) { - long id = c.getLong(0); - // Filter out the program whose channel is already searched. - if (previousResults.contains(id)) { - return false; - } - // The channel is cached. - Boolean isSearchable = searchableMap.get(id); - if (isSearchable != null) { - return isSearchable; - } + try (Cursor c = mContentResolver.query(Programs.CONTENT_URI, projection, selection, + selectionArgs, null)) { + if (c != null) { + int count = 0; + while (c.moveToNext()) { + long id = c.getLong(0); + // Filter out the program whose channel is already searched. + if (channelsFound.contains(id)) { + continue; + } + channelsFound.add(id); - // Don't know whether the channel is searchable or not. - String selection = Channels._ID + "=? AND " - + Channels.COLUMN_BROWSABLE + "=1 AND " - + Channels.COLUMN_SEARCHABLE + "=1"; - Cursor cursor = null; - try { - // Don't need to fetch all the columns. - cursor = context.getContentResolver().query(Channels.CONTENT_URI, - new String[] { Channels._ID }, selection, - new String[] { String.valueOf(id) }, null); - boolean isSearchableChannel = cursor != null && cursor.getCount() > 0; - searchableMap.put(id, isSearchableChannel); - return isSearchableChannel; - } finally { - if (cursor != null) { - cursor.close(); + // Don't know whether the channel is searchable or not. + String[] channelProjection = { + Channels._ID, + Channels.COLUMN_DISPLAY_NUMBER, + Channels.COLUMN_DISPLAY_NAME + }; + sb = new StringBuilder(); + sb.append(Channels._ID).append("=? AND ") + .append(Channels.COLUMN_BROWSABLE).append("=1 AND ") + .append(Channels.COLUMN_SEARCHABLE).append("=1"); + if (mTvInputManager.isParentalControlsEnabled()) { + sb.append(" AND ").append(Channels.COLUMN_LOCKED).append("=0"); + } + String selectionChannel = sb.toString(); + try (Cursor cChannel = mContentResolver.query(Channels.CONTENT_URI, + channelProjection, selectionChannel, + new String[] { String.valueOf(id) }, null)) { + if (cChannel != null && cChannel.moveToNext() + && !isRatingBlocked(c.getString(3))) { + long startUtcMillis = c.getLong(6); + long endUtcMillis = c.getLong(7); + SearchResult result = new SearchResult(); + result.channelId = c.getLong(0); + result.title = c.getString(1); + result.description = buildProgramDescription(cChannel.getString(1), + cChannel.getString(2), startUtcMillis, endUtcMillis); + result.imageUri = c.getString(2); + result.intentAction = Intent.ACTION_VIEW; + result.intentData = buildIntentData(id); + result.contentType = Programs.CONTENT_ITEM_TYPE; + result.isLive = true; + result.videoWidth = c.getInt(4); + result.videoHeight = c.getInt(5); + result.duration = endUtcMillis - startUtcMillis; + result.progressPercentage = getProgressPercentage(startUtcMillis, + endUtcMillis); + searchResults.add(result); + + if (limit != NO_LIMIT && ++count >= limit) { + break; } } } - }); + } + } + } + return searchResults; } - private static List<SearchResult> search(Context context, Uri uri, String[] projection, - String selection, String[] selectionArgs, int limit, ResultFilter resultFilter) { - List<SearchResult> results = new ArrayList<SearchResult>(); - - Cursor cursor = null; - try { - cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, - null); - if (cursor != null) { - // TODO: Need to add image when available. - int count = 0; - while (cursor.moveToNext()) { - if (resultFilter != null && !resultFilter.filter(cursor)) { - continue; - } + private String buildIntentData(long channelId) { + return TvContract.buildChannelUri(channelId).buildUpon() + .appendQueryParameter(Utils.PARAM_SOURCE, SOURCE_TV_SEARCH) + .build().toString(); + } - long id = cursor.getLong(0); - String title = cursor.getString(1); - String description = cursor.getString(2); - - SearchResult result = SearchResult.builder() - .setChannelId(id) - .setTitle(title) - .setDescription(description) - .setIntentAction(Intent.ACTION_VIEW) - .setIntentData(ContentUris.withAppendedId(Channels.CONTENT_URI, id) - .toString()) - .build(); - results.add(result); - - if (++count >= limit) { - break; - } + private boolean isRatingBlocked(String ratings) { + if (ratings == null) { + return false; + } + for (String rating : ratings.split("\\s*,\\s*")) { + try { + if (mTvInputManager.isParentalControlsEnabled() && mTvInputManager.isRatingBlocked( + TvContentRating.unflattenFromString(rating))) { + return true; } + } catch (IllegalArgumentException e) { + // Do nothing. } - } finally { - if (cursor != null) { - cursor.close(); + } + return false; + } + + private List<SearchResult> searchInputs(String query, int limit) { + if (DEBUG) { + Log.d(TAG, "searchInputs(" + query + ", limit=" + limit + ")"); + } + + query = canonicalizeLabel(query); + List<TvInputInfo> inputList = mTvInputManager.getTvInputList(); + List<SearchResult> results = new ArrayList<>(); + + // Find exact matches first. + for (TvInputInfo input : inputList) { + String label = canonicalizeLabel(input.loadLabel(mContext)); + String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext)); + if (TextUtils.equals(query, label) || TextUtils.equals(query, customLabel)) { + results.add(buildSearchResultForInput(input.getId())); + if (results.size() >= limit) { + return results; + } } } + // Then look for partial matches. + for (TvInputInfo input : inputList) { + String label = canonicalizeLabel(input.loadLabel(mContext)); + String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext)); + if ((label != null && label.contains(query)) || + (customLabel != null && customLabel.contains(query))) { + results.add(buildSearchResultForInput(input.getId())); + if (results.size() >= limit) { + return results; + } + } + } return results; } - private interface ResultFilter { - boolean filter(Cursor c); + private String canonicalizeLabel(CharSequence cs) { + Locale locale = mContext.getResources().getConfiguration().locale; + return cs != null ? cs.toString().replaceAll("[ -]", "").toLowerCase(locale) : null; + } + + private SearchResult buildSearchResultForInput(String inputId) { + SearchResult result = new SearchResult(); + result.intentAction = Intent.ACTION_VIEW; + result.intentData = TvContract.buildChannelUriForPassthroughInput(inputId).toString(); + return result; + } + + private class ChannelComparatorWithSameDisplayNumber implements Comparator<SearchResult> { + private final Map<Long, Long> mMaxWatchStartTimeMap = new HashMap<>(); + + @Override + public int compare(SearchResult lhs, SearchResult rhs) { + // Show recently watched channel first + Long lhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(lhs.channelId); + if (lhsMaxWatchStartTime == null) { + lhsMaxWatchStartTime = getMaxWatchStartTime(lhs.channelId); + mMaxWatchStartTimeMap.put(lhs.channelId, lhsMaxWatchStartTime); + } + Long rhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(rhs.channelId); + if (rhsMaxWatchStartTime == null) { + rhsMaxWatchStartTime = getMaxWatchStartTime(rhs.channelId); + mMaxWatchStartTimeMap.put(rhs.channelId, rhsMaxWatchStartTime); + } + if (!Objects.equals(lhsMaxWatchStartTime, rhsMaxWatchStartTime)) { + return Long.compare(rhsMaxWatchStartTime, lhsMaxWatchStartTime); + } + // Show recently added channel first if there's no watch history. + return Long.compare(rhs.channelId, lhs.channelId); + } + + private long getMaxWatchStartTime(long channelId) { + Uri uri = WatchedPrograms.CONTENT_URI; + String[] projections = new String[] { + "MAX(" + WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS + + ") AS max_watch_start_time" + }; + String selection = WatchedPrograms.COLUMN_CHANNEL_ID + "=?"; + String[] selectionArgs = new String[] { Long.toString(channelId) }; + try (Cursor c = mContentResolver.query(uri, projections, selection, selectionArgs, + null)) { + if (c != null && c.moveToNext()) { + return c.getLong(0); + } + } + return -1; + } } } |