/* * 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.search.LocalSearchProvider.SearchResult; import com.android.tv.util.PermissionUtils; import com.android.tv.util.Utils; 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; /** * 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 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 search(String query, int limit, int action) { List results = new ArrayList<>(); if (!PermissionUtils.hasAccessAllEpg(mContext)) { // TODO: support this feature for non-system LC app. b/23939816 return results; } Set 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 searchChannels(String query, Set channels, int limit) { if (DEBUG) Log.d(TAG, "Searching channels: '" + query + "'"); long time = SystemClock.elapsedRealtime(); List 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 (SearchResult result : results) { fillProgramInfo(result); } if (DEBUG) { Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for searching" + " channels: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); } return results; } @WorkerThread private List searchChannels(String query, String[] columnForExactMatching, String[] columnForPartialMatching, Set 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 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. */ @WorkerThread 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; } 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)); } @WorkerThread private List searchPrograms(String query, String[] columnForExactMatching, String[] columnForPartialMatching, Set 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 }; 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]; selectionArgs[0] = selectionArgs[1] = String.valueOf(System.currentTimeMillis()); insertSelectionArgumentStrings(selectionArgs, 2, query, columnForExactMatching, columnForPartialMatching); List 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 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; } } } } } } 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; } TvContentRating[] ratingArray = mTvContentRatingCache.getRatings(ratings); if (ratingArray != null) { for (TvContentRating r : ratingArray) { if (mTvInputManager.isRatingBlocked(r)) { return true; } } } return false; } private List searchInputs(String query, int limit) { if (DEBUG) Log.d(TAG, "Searching inputs: '" + query + "'"); long time = SystemClock.elapsedRealtime(); query = canonicalizeLabel(query); List inputList = mTvInputManager.getTvInputList(); List 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 result = new SearchResult(); result.intentAction = Intent.ACTION_VIEW; result.intentData = TvContract.buildChannelUriForPassthroughInput(inputId).toString(); return result; } @WorkerThread private class ChannelComparatorWithSameDisplayNumber implements Comparator { private final Map 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; } } }