/* * 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 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 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 tracks) { Set 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. * *

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. * *

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 getInternalTvInputs(Context context, boolean tunerInputOnly) { List 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(); } }