blob: e7d8a02d141bace40e321decd49425c44d428751 [file] [log] [blame]
/*
* 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<SearchResult> search(String query, int limit, int action) {
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 (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<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 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<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
};
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<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 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<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 result = new SearchResult();
result.intentAction = Intent.ACTION_VIEW;
result.intentData = TvContract.buildChannelUriForPassthroughInput(inputId).toString();
return result;
}
@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.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;
}
}
}