blob: a763fe583f670aed3e67bced9e3e41c9d4764ae3 [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.util;
import android.annotation.SuppressLint;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.content.res.Resources.Theme;
import android.database.Cursor;
import android.media.tv.TvContract;
import android.media.tv.TvContract.Channels;
import android.media.tv.TvInputInfo;
import android.media.tv.TvTrackInfo;
import android.net.Uri;
import android.os.Build;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;
import android.view.View;
import android.widget.Toast;
import com.android.tv.R;
import com.android.tv.TvApplication;
import com.android.tv.data.Channel;
import com.android.tv.data.Program;
import com.android.tv.data.StreamInfo;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
/**
* A class that includes convenience methods for accessing TvProvider database.
*/
public class Utils {
private static final String TAG = "Utils";
private static final boolean DEBUG = false;
private static final SimpleDateFormat ISO_8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
public static final String EXTRA_KEY_KEYCODE = "keycode";
public static final String EXTRA_KEY_ACTION = "action";
public static final String EXTRA_ACTION_SHOW_TV_INPUT ="show_tv_input";
public static final String EXTRA_KEY_FROM_LAUNCHER = "from_launcher";
public static final String EXTRA_KEY_RECORDING_URI = "recording_uri";
// Query parameter in the intent of starting MainActivity.
public static final String PARAM_SOURCE = "source";
private static final String PATH_CHANNEL = "channel";
private static final String PATH_PROGRAM = "program";
private static final String PREF_KEY_LAST_WATCHED_CHANNEL_ID = "last_watched_channel_id";
private static final String PREF_KEY_LAST_WATCHED_CHANNEL_ID_FOR_INPUT =
"last_watched_channel_id_for_input_";
private static final String PREF_KEY_LAST_WATCHED_CHANNEL_URI = "last_watched_channel_uri";
private static final int VIDEO_SD_WIDTH = 704;
private static final int VIDEO_SD_HEIGHT = 480;
private static final int VIDEO_HD_WIDTH = 1280;
private static final int VIDEO_HD_HEIGHT = 720;
private static final int VIDEO_FULL_HD_WIDTH = 1920;
private static final int VIDEO_FULL_HD_HEIGHT = 1080;
private static final int VIDEO_ULTRA_HD_WIDTH = 2048;
private static final int VIDEO_ULTRA_HD_HEIGHT = 1536;
private static final int AUDIO_CHANNEL_NONE = 0;
private static final int AUDIO_CHANNEL_MONO = 1;
private static final int AUDIO_CHANNEL_STEREO = 2;
private static final int AUDIO_CHANNEL_SURROUND_6 = 6;
private static final int AUDIO_CHANNEL_SURROUND_8 = 8;
private enum AspectRatio {
ASPECT_RATIO_4_3(4, 3),
ASPECT_RATIO_16_9(16, 9),
ASPECT_RATIO_21_9(21, 9);
final int width;
final int height;
AspectRatio(int width, int height) {
this.width = width;
this.height = height;
}
@Override
@SuppressLint("DefaultLocale")
public String toString() {
return String.format("%d:%d", width, height);
}
}
private Utils() {
}
public static String buildSelectionForIds(String idName, List<Long> ids) {
StringBuilder sb = new StringBuilder();
sb.append(idName).append(" in (")
.append(ids.get(0));
for (int i = 1; i < ids.size(); ++i) {
sb.append(",").append(ids.get(i));
}
sb.append(")");
return sb.toString();
}
@WorkerThread
public static String getInputIdForChannel(Context context, long channelId) {
if (channelId == Channel.INVALID_ID) {
return null;
}
Uri channelUri = TvContract.buildChannelUri(channelId);
String[] projection = {TvContract.Channels.COLUMN_INPUT_ID};
try (Cursor cursor = context.getContentResolver()
.query(channelUri, projection, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return Utils.intern(cursor.getString(0));
}
}
return null;
}
public static void setLastWatchedChannel(Context context, Channel channel) {
if (channel == null) {
Log.e(TAG, "setLastWatchedChannel: channel cannot be null");
return;
}
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putString(PREF_KEY_LAST_WATCHED_CHANNEL_URI, channel.getUri().toString()).apply();
if (!channel.isPassthrough()) {
long channelId = channel.getId();
if (channel.getId() < 0) {
throw new IllegalArgumentException("channelId should be equal to or larger than 0");
}
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID, channelId).apply();
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID_FOR_INPUT + channel.getInputId(),
channelId).apply();
}
}
public static long getLastWatchedChannelId(Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID, Channel.INVALID_ID);
}
public static long getLastWatchedChannelIdForInput(Context context, String inputId) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID_FOR_INPUT + inputId, Channel.INVALID_ID);
}
public static String getLastWatchedChannelUri(Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getString(PREF_KEY_LAST_WATCHED_CHANNEL_URI, null);
}
/**
* Returns {@code true}, if {@code uri} specifies an input, which is usually generated
* from {@link TvContract#buildChannelsUriForInput}.
*/
public static boolean isChannelUriForInput(Uri uri) {
return isTvUri(uri) && PATH_CHANNEL.equals(uri.getPathSegments().get(0))
&& !TextUtils.isEmpty(uri.getQueryParameter("input"));
}
/**
* Returns {@code true}, if {@code uri} is a channel URI for a specific channel. It is copied
* from the hidden method TvContract.isChannelUri.
*/
public static boolean isChannelUriForOneChannel(Uri uri) {
return isChannelUriForTunerInput(uri) || TvContract.isChannelUriForPassthroughInput(uri);
}
/**
* Returns {@code true}, if {@code uri} is a channel URI for a tuner input. It is copied from
* the hidden method TvContract.isChannelUriForTunerInput.
*/
public static boolean isChannelUriForTunerInput(Uri uri) {
return isTvUri(uri) && isTwoSegmentUriStartingWith(uri, PATH_CHANNEL);
}
private static boolean isTvUri(Uri uri) {
return uri != null && ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())
&& TvContract.AUTHORITY.equals(uri.getAuthority());
}
private static boolean isTwoSegmentUriStartingWith(Uri uri, String pathSegment) {
List<String> pathSegments = uri.getPathSegments();
return pathSegments.size() == 2 && pathSegment.equals(pathSegments.get(0));
}
/**
* Returns {@code true}, if {@code uri} is a programs URI.
*/
public static boolean isProgramsUri(Uri uri) {
return isTvUri(uri) && PATH_PROGRAM.equals(uri.getPathSegments().get(0));
}
/**
* Gets the info of the program on particular time.
*/
@WorkerThread
public static Program getProgramAt(Context context, long channelId, long timeMs) {
if (channelId == Channel.INVALID_ID) {
Log.e(TAG, "getCurrentProgramAt - channelId is invalid");
return null;
}
if (context.getMainLooper().getThread().equals(Thread.currentThread())) {
String message = "getCurrentProgramAt called on main thread";
if (DEBUG) {
// Generating a stack trace can be expensive, only do it in debug mode.
Log.w(TAG, message, new IllegalStateException(message));
} else {
Log.w(TAG, message);
}
}
Uri uri = TvContract.buildProgramsUriForChannel(TvContract.buildChannelUri(channelId),
timeMs, timeMs);
try (Cursor cursor = context.getContentResolver().query(uri, Program.PROJECTION,
null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return Program.fromCursor(cursor);
}
}
return null;
}
/**
* Gets the info of the current program.
*/
@WorkerThread
public static Program getCurrentProgram(Context context, long channelId) {
return getProgramAt(context, channelId, System.currentTimeMillis());
}
/**
* Returns duration string according to the date & time format.
* If {@code startUtcMillis} and {@code endUtcMills} are equal,
* formatted time will be returned instead.
*
* @param startUtcMillis start of duration in millis. Should be less than {code endUtcMillis}.
* @param endUtcMillis end of duration in millis. Should be larger than {@code startUtcMillis}.
* @param useShortFormat {@code true} if abbreviation is needed to save space.
* In that case, date will be omitted if duration starts from today
* and is less than a day. If it's necessary,
* {@link DateUtils#FORMAT_NUMERIC_DATE} is used otherwise.
*/
public static String getDurationString(
Context context, long startUtcMillis, long endUtcMillis, boolean useShortFormat) {
return getDurationString(context, System.currentTimeMillis(), startUtcMillis, endUtcMillis,
useShortFormat, 0);
}
@VisibleForTesting
static String getDurationString(Context context, long baseMillis,
long startUtcMillis, long endUtcMillis, boolean useShortFormat, int flag) {
flag |= DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_TIME
| ((useShortFormat) ? DateUtils.FORMAT_NUMERIC_DATE : 0);
if (!isInGivenDay(baseMillis, startUtcMillis)) {
flag |= DateUtils.FORMAT_SHOW_DATE;
}
if (startUtcMillis != endUtcMillis && useShortFormat) {
// Do special handling for 12:00 AM when checking if it's in the given day.
// If it's start, it's considered as beginning of the day. (e.g. 12:00 AM - 12:30 AM)
// If it's end, it's considered as end of the day (e.g. 11:00 PM - 12:00 AM)
if (!isInGivenDay(startUtcMillis, endUtcMillis - 1)
&& endUtcMillis - startUtcMillis < TimeUnit.HOURS.toMillis(11)) {
// Do not show date for short format.
// Extracting a day is needed because {@link DateUtils@formatDateRange}
// adds date if the duration covers multiple days.
return DateUtils.formatDateRange(context,
startUtcMillis, endUtcMillis - TimeUnit.DAYS.toMillis(1), flag);
}
}
return DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis, flag);
}
@VisibleForTesting
public static boolean isInGivenDay(long dayToMatchInMillis, long subjectTimeInMillis) {
final long DAY_IN_MS = TimeUnit.DAYS.toMillis(1);
TimeZone timeZone = Calendar.getInstance().getTimeZone();
long offset = timeZone.getRawOffset();
if (timeZone.inDaylightTime(new Date(dayToMatchInMillis))) {
offset += timeZone.getDSTSavings();
}
return Utils.floorTime(dayToMatchInMillis + offset, DAY_IN_MS)
== Utils.floorTime(subjectTimeInMillis + offset, DAY_IN_MS);
}
public static String getAspectRatioString(int width, int height) {
if (width == 0 || height == 0) {
return "";
}
for (AspectRatio ratio: AspectRatio.values()) {
if (Math.abs((float) ratio.height / ratio.width - (float) height / width) < 0.05f) {
return ratio.toString();
}
}
return "";
}
public static String getAspectRatioString(float videoDisplayAspectRatio) {
if (videoDisplayAspectRatio <= 0) {
return "";
}
for (AspectRatio ratio : AspectRatio.values()) {
if (Math.abs((float) ratio.width / ratio.height - videoDisplayAspectRatio) < 0.05f) {
return ratio.toString();
}
}
return "";
}
public static int getVideoDefinitionLevelFromSize(int width, int height) {
if (width >= VIDEO_ULTRA_HD_WIDTH && height >= VIDEO_ULTRA_HD_HEIGHT) {
return StreamInfo.VIDEO_DEFINITION_LEVEL_ULTRA_HD;
} else if (width >= VIDEO_FULL_HD_WIDTH && height >= VIDEO_FULL_HD_HEIGHT) {
return StreamInfo.VIDEO_DEFINITION_LEVEL_FULL_HD;
} else if (width >= VIDEO_HD_WIDTH && height >= VIDEO_HD_HEIGHT) {
return StreamInfo.VIDEO_DEFINITION_LEVEL_HD;
} else if (width >= VIDEO_SD_WIDTH && height >= VIDEO_SD_HEIGHT) {
return StreamInfo.VIDEO_DEFINITION_LEVEL_SD;
}
return StreamInfo.VIDEO_DEFINITION_LEVEL_UNKNOWN;
}
public static String getVideoDefinitionLevelString(Context context, int videoFormat) {
switch (videoFormat) {
case StreamInfo.VIDEO_DEFINITION_LEVEL_ULTRA_HD:
return context.getResources().getString(
R.string.video_definition_level_ultra_hd);
case StreamInfo.VIDEO_DEFINITION_LEVEL_FULL_HD:
return context.getResources().getString(
R.string.video_definition_level_full_hd);
case StreamInfo.VIDEO_DEFINITION_LEVEL_HD:
return context.getResources().getString(R.string.video_definition_level_hd);
case StreamInfo.VIDEO_DEFINITION_LEVEL_SD:
return context.getResources().getString(R.string.video_definition_level_sd);
}
return "";
}
public static String getAudioChannelString(Context context, int channelCount) {
switch (channelCount) {
case 1:
return context.getResources().getString(R.string.audio_channel_mono);
case 2:
return context.getResources().getString(R.string.audio_channel_stereo);
case 6:
return context.getResources().getString(R.string.audio_channel_5_1);
case 8:
return context.getResources().getString(R.string.audio_channel_7_1);
}
return "";
}
public static boolean needToShowSampleRate(Context context, List<TvTrackInfo> tracks) {
Set<String> multiAudioStrings = new HashSet<>();
for (TvTrackInfo track : tracks) {
String multiAudioString = getMultiAudioString(context, track, false);
if (multiAudioStrings.contains(multiAudioString)) {
return true;
}
multiAudioStrings.add(multiAudioString);
}
return false;
}
public static String getMultiAudioString(Context context, TvTrackInfo track,
boolean showSampleRate) {
if (track.getType() != TvTrackInfo.TYPE_AUDIO) {
throw new IllegalArgumentException("Not an audio track: " + track);
}
String language = context.getString(R.string.default_language);
if (!TextUtils.isEmpty(track.getLanguage())) {
language = new Locale(track.getLanguage()).getDisplayName();
} else {
Log.d(TAG, "No language information found for the audio track: " + track);
}
StringBuilder metadata = new StringBuilder();
switch (track.getAudioChannelCount()) {
case AUDIO_CHANNEL_NONE:
break;
case AUDIO_CHANNEL_MONO:
metadata.append(context.getString(R.string.multi_audio_channel_mono));
break;
case AUDIO_CHANNEL_STEREO:
metadata.append(context.getString(R.string.multi_audio_channel_stereo));
break;
case AUDIO_CHANNEL_SURROUND_6:
metadata.append(context.getString(R.string.multi_audio_channel_surround_6));
break;
case AUDIO_CHANNEL_SURROUND_8:
metadata.append(context.getString(R.string.multi_audio_channel_surround_8));
break;
default:
if (track.getAudioChannelCount() > 0) {
metadata.append(context.getString(R.string.multi_audio_channel_suffix,
track.getAudioChannelCount()));
} else {
Log.d(TAG, "Invalid audio channel count (" + track.getAudioChannelCount()
+ ") found for the audio track: " + track);
}
break;
}
if (showSampleRate) {
int sampleRate = track.getAudioSampleRate();
if (sampleRate > 0) {
if (metadata.length() > 0) {
metadata.append(", ");
}
int integerPart = sampleRate / 1000;
int tenths = (sampleRate % 1000) / 100;
metadata.append(integerPart);
if (tenths != 0) {
metadata.append(".");
metadata.append(tenths);
}
metadata.append("kHz");
}
}
if (metadata.length() == 0) {
return language;
}
return context.getString(R.string.multi_audio_display_string_with_channel, language,
metadata.toString());
}
public static boolean isEqualLanguage(String lang1, String lang2) {
if (lang1 == null) {
return lang2 == null;
} else if (lang2 == null) {
return false;
}
try {
return TextUtils.equals(
new Locale(lang1).getISO3Language(), new Locale(lang2).getISO3Language());
} catch (Exception ignored) {
}
return false;
}
public static boolean isIntentAvailable(Context context, Intent intent) {
return context.getPackageManager().queryIntentActivities(
intent, PackageManager.MATCH_DEFAULT_ONLY).size() > 0;
}
/**
* Returns the label for a given input. Returns the custom label, if any.
*/
public static String loadLabel(Context context, TvInputInfo input) {
if (input == null) {
return null;
}
CharSequence customLabel = input.loadCustomLabel(context);
String label = (customLabel == null) ? null : customLabel.toString();
if (TextUtils.isEmpty(label)) {
label = input.loadLabel(context).toString();
}
return label;
}
/**
* Enable all channels synchronously.
*/
@WorkerThread
public static void enableAllChannels(Context context) {
ContentValues values = new ContentValues();
values.put(Channels.COLUMN_BROWSABLE, 1);
context.getContentResolver().update(Channels.CONTENT_URI, values, null, null);
}
/**
* Converts time in milliseconds to a String.
*/
public static String toTimeString(long timeMillis) {
return new Date(timeMillis).toString();
}
/**
* Converts time in milliseconds to a ISO 8061 string.
*/
public static String toIsoDateTimeString(long timeMillis) {
return ISO_8601.format(new Date(timeMillis));
}
/**
* Returns a {@link String} object which contains the layout information of the {@code view}.
*/
public static String toRectString(View view) {
return "{"
+ "l=" + view.getLeft()
+ ",r=" + view.getRight()
+ ",t=" + view.getTop()
+ ",b=" + view.getBottom()
+ ",w=" + view.getWidth()
+ ",h=" + view.getHeight() + "}";
}
/**
* Floors time to the given {@code timeUnit}. For example, if time is 5:32:11 and timeUnit is
* one hour (60 * 60 * 1000), then the output will be 5:00:00.
*/
public static long floorTime(long timeMs, long timeUnit) {
return timeMs - (timeMs % timeUnit);
}
/**
* Ceils time to the given {@code timeUnit}. For example, if time is 5:32:11 and timeUnit is
* one hour (60 * 60 * 1000), then the output will be 6:00:00.
*/
public static long ceilTime(long timeMs, long timeUnit) {
return timeMs + timeUnit - (timeMs % timeUnit);
}
/**
* Returns an {@link String#intern() interned} string or null if the input is null.
*/
@Nullable
public static String intern(@Nullable String string) {
return string == null ? null : string.intern();
}
/**
* Check if the index is valid for the collection,
* @param collection the collection
* @param index the index position to test
* @return index >= 0 && index < collection.size().
*/
public static boolean isIndexValid(@Nullable Collection<?> collection, int index) {
return collection == null ? false : index >= 0 && index < collection.size();
}
/**
* Returns a color integer associated with a particular resource ID.
*
* @see #getColor(android.content.res.Resources,int,Theme)
*/
public static int getColor(Resources res, int id) {
return getColor(res, id, null);
}
/**
* Returns a color integer associated with a particular resource ID.
*
* <p>In M version, {@link android.content.res.Resources#getColor(int)} was deprecated and
* {@link android.content.res.Resources#getColor(int,Theme)} was newly added.
*
* @see android.content.res.Resources#getColor(int)
*/
public static int getColor(Resources res, int id, @Nullable Theme theme) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return res.getColor(id, theme);
} else {
return res.getColor(id);
}
}
/**
* Returns a color state list associated with a particular resource ID.
*
* @see #getColorStateList(android.content.res.Resources,int,Theme)
*/
public static ColorStateList getColorStateList(Resources res, int id) {
return getColorStateList(res, id, null);
}
/**
* Returns a color state list associated with a particular resource ID.
*
* <p>In M version, {@link android.content.res.Resources#getColorStateList(int)} was deprecated
* and {@link android.content.res.Resources#getColorStateList(int,Theme)} was newly added.
*
* @see android.content.res.Resources#getColorStateList(int)
*/
public static ColorStateList getColorStateList(Resources res, int id, @Nullable Theme theme) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return res.getColorStateList(id, theme);
} else {
return res.getColorStateList(id);
}
}
/**
* Returns a localized version of the text resource specified by resourceId.
*/
public static CharSequence getTextForLocale(Context context, Locale locale, int resourceId) {
if (locale.equals(context.getResources().getConfiguration().locale)) {
return context.getText(resourceId);
}
Configuration config = new Configuration(context.getResources().getConfiguration());
config.setLocale(locale);
return context.createConfigurationContext(config).getText(resourceId);
}
/**
* Returns the internal TV inputs.
*/
public static List<TvInputInfo> getInternalTvInputs(Context context, boolean tunerInputOnly) {
List<TvInputInfo> inputs = new ArrayList<>();
String contextPackageName = context.getPackageName();
for (TvInputInfo input : TvApplication.getSingletons(context).getTvInputManagerHelper()
.getTvInputInfos(true, tunerInputOnly)) {
if (contextPackageName.equals(ComponentName.unflattenFromString(input.getId())
.getPackageName())) {
inputs.add(input);
}
}
return inputs;
}
/**
* Checks whether the input is internal or not.
*/
public static boolean isInternalTvInput(Context context, String inputId) {
return context.getPackageName().equals(ComponentName.unflattenFromString(inputId)
.getPackageName());
}
/**
* Shows a toast message to notice that the current feature is a developer feature.
*/
public static void showToastMessageForDeveloperFeature(Context context) {
Toast.makeText(context, "This feature is for developer preview.", Toast.LENGTH_SHORT)
.show();
}
}