| /* |
| * 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. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.tv.search; |
| |
| 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.os.SystemClock; |
| import android.support.annotation.WorkerThread; |
| import android.text.TextUtils; |
| import android.util.Log; |
| import com.android.tv.common.TvContentRatingCache; |
| import com.android.tv.common.util.PermissionUtils; |
| import com.android.tv.search.LocalSearchProvider.SearchResult; |
| import com.android.tv.util.Utils; |
| import com.google.common.collect.ImmutableList; |
| 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; |
| import java.util.concurrent.TimeUnit; |
| |
| /** An implementation of {@link SearchInterface} to search query from TvProvider directly. */ |
| public class TvProviderSearch implements SearchInterface { |
| private static final String TAG = "TvProviderSearch"; |
| private static final boolean DEBUG = false; |
| |
| private static final long SEARCH_TIME_FRAME_MS = TimeUnit.DAYS.toMillis(14); |
| |
| private static final int NO_LIMIT = 0; |
| |
| private final Context mContext; |
| private final ContentResolver mContentResolver; |
| private final TvInputManager mTvInputManager; |
| private final TvContentRatingCache mTvContentRatingCache = TvContentRatingCache.getInstance(); |
| |
| 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}, |
| */ |
| @Override |
| @WorkerThread |
| public List<SearchResult> search(String query, int limit, int action) { |
| // TODO(b/72499463): add a test. |
| List<SearchResult> results = new ArrayList<>(); |
| if (!PermissionUtils.hasAccessAllEpg(mContext)) { |
| // TODO: support this feature for non-system LC app. b/23939816 |
| return results; |
| } |
| 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 void 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 ?"); |
| } |
| } |
| } |
| |
| 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; |
| } |
| } |
| } |
| |
| @WorkerThread |
| private List<SearchResult> searchChannels(String query, Set<Long> channels, int limit) { |
| if (DEBUG) Log.d(TAG, "Searching channels: '" + query + "'"); |
| long time = SystemClock.elapsedRealtime(); |
| 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())); |
| } |
| if (results.size() > limit) { |
| results = results.subList(0, limit); |
| } |
| for (int i = 0; i < results.size(); i++) { |
| results.set(i, fillProgramInfo(results.get(i))); |
| } |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "Found " |
| + results.size() |
| + " channels. Elapsed time for searching" |
| + " channels: " |
| + (SystemClock.elapsedRealtime() - time) |
| + "(msec)"); |
| } |
| return results; |
| } |
| |
| @WorkerThread |
| private List<SearchResult> searchChannels( |
| String query, |
| String[] columnForExactMatching, |
| String[] columnForPartialMatching, |
| Set<Long> channelsFound, |
| int limit) { |
| String[] projection = { |
| Channels._ID, |
| Channels.COLUMN_DISPLAY_NUMBER, |
| Channels.COLUMN_DISPLAY_NAME, |
| Channels.COLUMN_DESCRIPTION |
| }; |
| |
| StringBuilder sb = new StringBuilder(); |
| 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(); |
| |
| 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.Builder result = SearchResult.builder(); |
| result.setChannelId(id); |
| result.setChannelNumber(c.getString(1)); |
| result.setTitle(c.getString(2)); |
| result.setDescription(c.getString(3)); |
| result.setImageUri(TvContract.buildChannelLogoUri(id).toString()); |
| result.setIntentAction(Intent.ACTION_VIEW); |
| result.setIntentData(buildIntentData(id)); |
| result.setContentType(Programs.CONTENT_ITEM_TYPE); |
| result.setIsLive(true); |
| result.setProgressPercentage(SearchInterface.PROGRESS_PERCENTAGE_HIDE); |
| |
| searchResults.add(result.build()); |
| |
| 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. |
| */ |
| @WorkerThread |
| private SearchResult fillProgramInfo(SearchResult result) { |
| long now = System.currentTimeMillis(); |
| Uri uri = TvContract.buildProgramsUriForChannel(result.getChannelId(), 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.getTitle(); |
| String channelNumber = result.getChannelNumber(); |
| SearchResult.Builder builder = SearchResult.builder(); |
| long startUtcMillis = c.getLong(5); |
| long endUtcMillis = c.getLong(6); |
| builder.setTitle(c.getString(0)); |
| builder.setDescription( |
| buildProgramDescription( |
| channelNumber, channelName, startUtcMillis, endUtcMillis)); |
| String imageUri = c.getString(1); |
| if (imageUri != null) { |
| builder.setImageUri(imageUri); |
| } |
| builder.setVideoWidth(c.getInt(3)); |
| builder.setVideoHeight(c.getInt(4)); |
| builder.setDuration(endUtcMillis - startUtcMillis); |
| builder.setProgressPercentage(getProgressPercentage(startUtcMillis, endUtcMillis)); |
| return builder.build(); |
| } |
| } |
| return result; |
| } |
| |
| private String buildProgramDescription( |
| String channelNumber, |
| String channelName, |
| long programStartUtcMillis, |
| long programEndUtcMillis) { |
| return Utils.getDurationString(mContext, programStartUtcMillis, programEndUtcMillis, false) |
| + System.lineSeparator() |
| + channelNumber |
| + " " |
| + channelName; |
| } |
| |
| private int getProgressPercentage(long startUtcMillis, long endUtcMillis) { |
| long current = System.currentTimeMillis(); |
| if (startUtcMillis > current || endUtcMillis <= current) { |
| return SearchInterface.PROGRESS_PERCENTAGE_HIDE; |
| } |
| return (int) (100 * (current - startUtcMillis) / (endUtcMillis - startUtcMillis)); |
| } |
| |
| @WorkerThread |
| private List<SearchResult> searchPrograms( |
| String query, |
| String[] columnForExactMatching, |
| String[] columnForPartialMatching, |
| Set<Long> channelsFound, |
| int limit) { |
| if (DEBUG) Log.d(TAG, "Searching programs: '" + query + "'"); |
| long time = SystemClock.elapsedRealtime(); |
| String[] projection = { |
| Programs.COLUMN_CHANNEL_ID, |
| 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, |
| Programs._ID |
| }; |
| |
| 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 ("); |
| appendSelectionString(sb, columnForExactMatching, columnForPartialMatching); |
| sb.append(")"); |
| String selection = sb.toString(); |
| |
| int len = |
| (columnForExactMatching == null ? 0 : columnForExactMatching.length) |
| + (columnForPartialMatching == null ? 0 : columnForPartialMatching.length); |
| String[] selectionArgs = new String[len + 2]; |
| long now = System.currentTimeMillis(); |
| selectionArgs[0] = String.valueOf(now + SEARCH_TIME_FRAME_MS); |
| selectionArgs[1] = String.valueOf(now); |
| insertSelectionArgumentStrings( |
| selectionArgs, 2, query, columnForExactMatching, columnForPartialMatching); |
| |
| List<SearchResult> searchResults = new ArrayList<>(); |
| |
| 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[] 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.Builder result = SearchResult.builder(); |
| result.setChannelId(c.getLong(0)); |
| result.setTitle(c.getString(1)); |
| result.setDescription( |
| buildProgramDescription( |
| cChannel.getString(1), |
| cChannel.getString(2), |
| startUtcMillis, |
| endUtcMillis)); |
| result.setImageUri(c.getString(2)); |
| result.setIntentAction(Intent.ACTION_VIEW); |
| result.setIntentData(buildIntentData(id)); |
| result.setIntentExtraData( |
| TvContract.buildProgramUri(c.getLong(8)).toString()); |
| result.setContentType(Programs.CONTENT_ITEM_TYPE); |
| result.setIsLive(true); |
| result.setVideoWidth(c.getInt(4)); |
| result.setVideoHeight(c.getInt(5)); |
| result.setDuration(endUtcMillis - startUtcMillis); |
| result.setProgressPercentage( |
| getProgressPercentage(startUtcMillis, endUtcMillis)); |
| searchResults.add(result.build()); |
| |
| if (limit != NO_LIMIT && ++count >= limit) { |
| break; |
| } |
| } |
| } |
| } |
| } |
| } |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "Found " |
| + searchResults.size() |
| + " programs. Elapsed time for searching" |
| + " programs: " |
| + (SystemClock.elapsedRealtime() - time) |
| + "(msec)"); |
| } |
| return searchResults; |
| } |
| |
| private String buildIntentData(long channelId) { |
| return TvContract.buildChannelUri(channelId).toString(); |
| } |
| |
| private boolean isRatingBlocked(String ratings) { |
| if (TextUtils.isEmpty(ratings) || !mTvInputManager.isParentalControlsEnabled()) { |
| return false; |
| } |
| ImmutableList<TvContentRating> ratingArray = mTvContentRatingCache.getRatings(ratings); |
| if (ratingArray != null) { |
| for (TvContentRating r : ratingArray) { |
| if (mTvInputManager.isRatingBlocked(r)) { |
| return true; |
| } |
| } |
| } |
| return false; |
| } |
| |
| private List<SearchResult> searchInputs(String query, int limit) { |
| if (DEBUG) Log.d(TAG, "Searching inputs: '" + query + "'"); |
| long time = SystemClock.elapsedRealtime(); |
| |
| query = canonicalizeLabel(query); |
| List<TvInputInfo> inputList = mTvInputManager.getTvInputList(); |
| List<SearchResult> results = new ArrayList<>(); |
| |
| // Find exact matches first. |
| for (TvInputInfo input : inputList) { |
| if (input.getType() == TvInputInfo.TYPE_TUNER) { |
| continue; |
| } |
| 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) { |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "Found " |
| + results.size() |
| + " inputs. Elapsed time for" |
| + " searching inputs: " |
| + (SystemClock.elapsedRealtime() - time) |
| + "(msec)"); |
| } |
| return results; |
| } |
| } |
| } |
| |
| // Then look for partial matches. |
| for (TvInputInfo input : inputList) { |
| if (input.getType() == TvInputInfo.TYPE_TUNER) { |
| continue; |
| } |
| 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) { |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "Found " |
| + results.size() |
| + " inputs. Elapsed time for" |
| + " searching inputs: " |
| + (SystemClock.elapsedRealtime() - time) |
| + "(msec)"); |
| } |
| return results; |
| } |
| } |
| } |
| if (DEBUG) { |
| Log.d( |
| TAG, |
| "Found " |
| + results.size() |
| + " inputs. Elapsed time for searching" |
| + " inputs: " |
| + (SystemClock.elapsedRealtime() - time) |
| + "(msec)"); |
| } |
| return results; |
| } |
| |
| 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.Builder result = SearchResult.builder(); |
| result.setIntentAction(Intent.ACTION_VIEW); |
| result.setIntentData(TvContract.buildChannelUriForPassthroughInput(inputId).toString()); |
| return result.build(); |
| } |
| |
| @WorkerThread |
| 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.getChannelId()); |
| if (lhsMaxWatchStartTime == null) { |
| lhsMaxWatchStartTime = getMaxWatchStartTime(lhs.getChannelId()); |
| mMaxWatchStartTimeMap.put(lhs.getChannelId(), lhsMaxWatchStartTime); |
| } |
| Long rhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(rhs.getChannelId()); |
| if (rhsMaxWatchStartTime == null) { |
| rhsMaxWatchStartTime = getMaxWatchStartTime(rhs.getChannelId()); |
| mMaxWatchStartTimeMap.put(rhs.getChannelId(), 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.getChannelId(), lhs.getChannelId()); |
| } |
| |
| 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; |
| } |
| } |
| } |