/* * 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 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 search(String query, int limit, int action) { // TODO(b/72499463): add a test. 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 (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 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.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(LocalSearchProvider.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 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, 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 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; } 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.Builder result = SearchResult.builder(); result.setIntentAction(Intent.ACTION_VIEW); result.setIntentData(TvContract.buildChannelUriForPassthroughInput(inputId).toString()); return result.build(); } @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.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; } } }