/* * 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.data; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.database.Cursor; import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.net.Uri; import android.support.annotation.Nullable; import android.support.annotation.UiThread; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; import com.android.tv.common.TvCommonConstants; import com.android.tv.util.ImageLoader; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import java.net.URISyntaxException; import java.util.Comparator; import java.util.HashMap; import java.util.Map; import java.util.Objects; /** * A convenience class to create and insert channel entries into the database. */ public final class Channel { private static final String TAG = "Channel"; public static final long INVALID_ID = -1; public static final int LOAD_IMAGE_TYPE_CHANNEL_LOGO = 1; public static final int LOAD_IMAGE_TYPE_APP_LINK_ICON = 2; public static final int LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART = 3; /** * Compares the channel numbers of channels which belong to the same input. */ public static final Comparator CHANNEL_NUMBER_COMPARATOR = new Comparator() { @Override public int compare(Channel lhs, Channel rhs) { return ChannelNumber.compare(lhs.getDisplayNumber(), rhs.getDisplayNumber()); } }; /** * When a TIS doesn't provide any information about app link, and it doesn't have a leanback * launch intent, there will be no app link card for the TIS. */ public static final int APP_LINK_TYPE_NONE = -1; /** * When a TIS provide a specific app link information, the app link card will be * {@code APP_LINK_TYPE_CHANNEL} which contains all the provided information. */ public static final int APP_LINK_TYPE_CHANNEL = 1; /** * When a TIS doesn't provide a specific app link information, but the app has a leanback launch * intent, the app link card will be {@code APP_LINK_TYPE_APP} which launches the application. */ public static final int APP_LINK_TYPE_APP = 2; private static final int APP_LINK_TYPE_NOT_SET = 0; private static final String INVALID_PACKAGE_NAME = "packageName"; public static final String[] PROJECTION = { // Columns must match what is read in Channel.fromCursor() TvContract.Channels._ID, TvContract.Channels.COLUMN_PACKAGE_NAME, TvContract.Channels.COLUMN_INPUT_ID, TvContract.Channels.COLUMN_TYPE, TvContract.Channels.COLUMN_DISPLAY_NUMBER, TvContract.Channels.COLUMN_DISPLAY_NAME, TvContract.Channels.COLUMN_DESCRIPTION, TvContract.Channels.COLUMN_VIDEO_FORMAT, TvContract.Channels.COLUMN_BROWSABLE, TvContract.Channels.COLUMN_LOCKED, TvContract.Channels.COLUMN_APP_LINK_TEXT, TvContract.Channels.COLUMN_APP_LINK_COLOR, TvContract.Channels.COLUMN_APP_LINK_ICON_URI, TvContract.Channels.COLUMN_APP_LINK_POSTER_ART_URI, TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG2, // Only used in bundled input }; /** * Channel number delimiter between major and minor parts. */ public static final char CHANNEL_NUMBER_DELIMITER = '-'; /** * Creates {@code Channel} object from cursor. * *

The query that created the cursor MUST use {@link #PROJECTION} * */ public static Channel fromCursor(Cursor cursor) { // Columns read must match the order of {@link #PROJECTION} Channel channel = new Channel(); int index = 0; channel.mId = cursor.getLong(index++); channel.mPackageName = Utils.intern(cursor.getString(index++)); channel.mInputId = Utils.intern(cursor.getString(index++)); channel.mType = Utils.intern(cursor.getString(index++)); channel.mDisplayNumber = normalizeDisplayNumber(cursor.getString(index++)); channel.mDisplayName = cursor.getString(index++); channel.mDescription = cursor.getString(index++); channel.mVideoFormat = Utils.intern(cursor.getString(index++)); channel.mBrowsable = cursor.getInt(index++) == 1; channel.mLocked = cursor.getInt(index++) == 1; channel.mAppLinkText = cursor.getString(index++); channel.mAppLinkColor = cursor.getInt(index++); channel.mAppLinkIconUri = cursor.getString(index++); channel.mAppLinkPosterArtUri = cursor.getString(index++); channel.mAppLinkIntentUri = cursor.getString(index++); if (Utils.isBundledInput(channel.mInputId)) { channel.mRecordingProhibited = cursor.getInt(index++) != 0; } return channel; } /** * Replaces the channel number separator with dash('-'). */ public static String normalizeDisplayNumber(String string) { if (!TextUtils.isEmpty(string)) { int length = string.length(); for (int i = 0; i < length; i++) { char c = string.charAt(i); if (c == '.' || Character.isWhitespace(c) || Character.getType(c) == Character.DASH_PUNCTUATION) { StringBuilder sb = new StringBuilder(string); sb.setCharAt(i, CHANNEL_NUMBER_DELIMITER); return sb.toString(); } } } return string; } /** ID of this channel. Matches to BaseColumns._ID. */ private long mId; private String mPackageName; private String mInputId; private String mType; private String mDisplayNumber; private String mDisplayName; private String mDescription; private String mVideoFormat; private boolean mBrowsable; private boolean mLocked; private boolean mIsPassthrough; private String mAppLinkText; private int mAppLinkColor; private String mAppLinkIconUri; private String mAppLinkPosterArtUri; private String mAppLinkIntentUri; private Intent mAppLinkIntent; private int mAppLinkType; private String mLogoUri; private boolean mRecordingProhibited; private boolean mChannelLogoExist; private Channel() { // Do nothing. } public long getId() { return mId; } public Uri getUri() { if (isPassthrough()) { return TvContract.buildChannelUriForPassthroughInput(mInputId); } else { return TvContract.buildChannelUri(mId); } } public String getPackageName() { return mPackageName; } public String getInputId() { return mInputId; } public String getType() { return mType; } public String getDisplayNumber() { return mDisplayNumber; } @Nullable public String getDisplayName() { return mDisplayName; } @VisibleForTesting public String getDescription() { return mDescription; } public String getVideoFormat() { return mVideoFormat; } public boolean isPassthrough() { return mIsPassthrough; } /** * Gets identification text for displaying or debugging. * It's made from Channels' display number plus their display name. */ public String getDisplayText() { return TextUtils.isEmpty(mDisplayName) ? mDisplayNumber : mDisplayNumber + " " + mDisplayName; } public String getAppLinkText() { return mAppLinkText; } public int getAppLinkColor() { return mAppLinkColor; } public String getAppLinkIconUri() { return mAppLinkIconUri; } public String getAppLinkPosterArtUri() { return mAppLinkPosterArtUri; } public String getAppLinkIntentUri() { return mAppLinkIntentUri; } /** * Returns channel logo uri which is got from cloud, it's used only for ChannelLogoFetcher. */ public String getLogoUri() { return mLogoUri; } public boolean isRecordingProhibited() { return mRecordingProhibited; } /** * Checks whether this channel is physical tuner channel or not. */ public boolean isPhysicalTunerChannel() { return !TextUtils.isEmpty(mType) && !TvContract.Channels.TYPE_OTHER.equals(mType); } /** * Checks if two channels equal by checking ids. */ @Override public boolean equals(Object o) { if (!(o instanceof Channel)) { return false; } Channel other = (Channel) o; // All pass-through TV channels have INVALID_ID value for mId. return mId == other.mId && TextUtils.equals(mInputId, other.mInputId) && mIsPassthrough == other.mIsPassthrough; } @Override public int hashCode() { return Objects.hash(mId, mInputId, mIsPassthrough); } public boolean isBrowsable() { return mBrowsable; } public boolean isLocked() { return mLocked; } public void setBrowsable(boolean browsable) { mBrowsable = browsable; } public void setLocked(boolean locked) { mLocked = locked; } /** * Sets channel logo uri which is got from cloud. */ public void setLogoUri(String logoUri) { mLogoUri = logoUri; } /** * Check whether {@code other} has same read-only channel info as this. But, it cannot check two * channels have same logos. It also excludes browsable and locked, because two fields are * changed by TV app. */ public boolean hasSameReadOnlyInfo(Channel other) { return other != null && Objects.equals(mId, other.mId) && Objects.equals(mPackageName, other.mPackageName) && Objects.equals(mInputId, other.mInputId) && Objects.equals(mType, other.mType) && Objects.equals(mDisplayNumber, other.mDisplayNumber) && Objects.equals(mDisplayName, other.mDisplayName) && Objects.equals(mDescription, other.mDescription) && Objects.equals(mVideoFormat, other.mVideoFormat) && mIsPassthrough == other.mIsPassthrough && Objects.equals(mAppLinkText, other.mAppLinkText) && mAppLinkColor == other.mAppLinkColor && Objects.equals(mAppLinkIconUri, other.mAppLinkIconUri) && Objects.equals(mAppLinkPosterArtUri, other.mAppLinkPosterArtUri) && Objects.equals(mAppLinkIntentUri, other.mAppLinkIntentUri) && Objects.equals(mRecordingProhibited, other.mRecordingProhibited); } @Override public String toString() { return "Channel{" + "id=" + mId + ", packageName=" + mPackageName + ", inputId=" + mInputId + ", type=" + mType + ", displayNumber=" + mDisplayNumber + ", displayName=" + mDisplayName + ", description=" + mDescription + ", videoFormat=" + mVideoFormat + ", isPassthrough=" + mIsPassthrough + ", browsable=" + mBrowsable + ", locked=" + mLocked + ", appLinkText=" + mAppLinkText + ", recordingProhibited=" + mRecordingProhibited + "}"; } void copyFrom(Channel other) { if (this == other) { return; } mId = other.mId; mPackageName = other.mPackageName; mInputId = other.mInputId; mType = other.mType; mDisplayNumber = other.mDisplayNumber; mDisplayName = other.mDisplayName; mDescription = other.mDescription; mVideoFormat = other.mVideoFormat; mIsPassthrough = other.mIsPassthrough; mBrowsable = other.mBrowsable; mLocked = other.mLocked; mAppLinkText = other.mAppLinkText; mAppLinkColor = other.mAppLinkColor; mAppLinkIconUri = other.mAppLinkIconUri; mAppLinkPosterArtUri = other.mAppLinkPosterArtUri; mAppLinkIntentUri = other.mAppLinkIntentUri; mAppLinkIntent = other.mAppLinkIntent; mAppLinkType = other.mAppLinkType; mRecordingProhibited = other.mRecordingProhibited; mChannelLogoExist = other.mChannelLogoExist; } /** * Creates a channel for a passthrough TV input. */ public static Channel createPassthroughChannel(Uri uri) { if (!TvContract.isChannelUriForPassthroughInput(uri)) { throw new IllegalArgumentException("URI is not a passthrough channel URI"); } String inputId = uri.getPathSegments().get(1); return createPassthroughChannel(inputId); } /** * Creates a channel for a passthrough TV input with {@code inputId}. */ public static Channel createPassthroughChannel(String inputId) { return new Builder() .setInputId(inputId) .setPassthrough(true) .build(); } /** * Checks whether the channel is valid or not. */ public static boolean isValid(Channel channel) { return channel != null && (channel.mId != INVALID_ID || channel.mIsPassthrough); } /** * Builder class for {@code Channel}. * Suppress using this outside of ChannelDataManager * so Channels could be managed by ChannelDataManager. */ public static final class Builder { private final Channel mChannel; public Builder() { mChannel = new Channel(); // Fill initial data. mChannel.mId = INVALID_ID; mChannel.mPackageName = INVALID_PACKAGE_NAME; mChannel.mInputId = "inputId"; mChannel.mType = "type"; mChannel.mDisplayNumber = "0"; mChannel.mDisplayName = "name"; mChannel.mDescription = "description"; mChannel.mBrowsable = true; } public Builder(Channel other) { mChannel = new Channel(); mChannel.copyFrom(other); } @VisibleForTesting public Builder setId(long id) { mChannel.mId = id; return this; } @VisibleForTesting public Builder setPackageName(String packageName) { mChannel.mPackageName = packageName; return this; } public Builder setInputId(String inputId) { mChannel.mInputId = inputId; return this; } public Builder setType(String type) { mChannel.mType = type; return this; } @VisibleForTesting public Builder setDisplayNumber(String displayNumber) { mChannel.mDisplayNumber = normalizeDisplayNumber(displayNumber); return this; } @VisibleForTesting public Builder setDisplayName(String displayName) { mChannel.mDisplayName = displayName; return this; } @VisibleForTesting public Builder setDescription(String description) { mChannel.mDescription = description; return this; } public Builder setVideoFormat(String videoFormat) { mChannel.mVideoFormat = videoFormat; return this; } public Builder setBrowsable(boolean browsable) { mChannel.mBrowsable = browsable; return this; } public Builder setLocked(boolean locked) { mChannel.mLocked = locked; return this; } public Builder setPassthrough(boolean isPassthrough) { mChannel.mIsPassthrough = isPassthrough; return this; } @VisibleForTesting public Builder setAppLinkText(String appLinkText) { mChannel.mAppLinkText = appLinkText; return this; } public Builder setAppLinkColor(int appLinkColor) { mChannel.mAppLinkColor = appLinkColor; return this; } public Builder setAppLinkIconUri(String appLinkIconUri) { mChannel.mAppLinkIconUri = appLinkIconUri; return this; } public Builder setAppLinkPosterArtUri(String appLinkPosterArtUri) { mChannel.mAppLinkPosterArtUri = appLinkPosterArtUri; return this; } @VisibleForTesting public Builder setAppLinkIntentUri(String appLinkIntentUri) { mChannel.mAppLinkIntentUri = appLinkIntentUri; return this; } public Builder setRecordingProhibited(boolean recordingProhibited) { mChannel.mRecordingProhibited = recordingProhibited; return this; } public Channel build() { Channel channel = new Channel(); channel.copyFrom(mChannel); return channel; } } /** * Prefetches the images for this channel. */ public void prefetchImage(Context context, int type, int maxWidth, int maxHeight) { String uriString = getImageUriString(type); if (!TextUtils.isEmpty(uriString)) { ImageLoader.prefetchBitmap(context, uriString, maxWidth, maxHeight); } } /** * Loads the bitmap of this channel and returns it via {@code callback}. * The loaded bitmap will be cached and resized with given params. *

* Note that it may directly call {@code callback} if the bitmap is already loaded. * * @param context A context. * @param type The type of bitmap which will be loaded. It should be one of follows: * {@link #LOAD_IMAGE_TYPE_CHANNEL_LOGO}, {@link #LOAD_IMAGE_TYPE_APP_LINK_ICON}, or * {@link #LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART}. * @param maxWidth The max width of the loaded bitmap. * @param maxHeight The max height of the loaded bitmap. * @param callback A callback which will be called after the loading finished. */ @UiThread public void loadBitmap(Context context, final int type, int maxWidth, int maxHeight, ImageLoader.ImageLoaderCallback callback) { String uriString = getImageUriString(type); ImageLoader.loadBitmap(context, uriString, maxWidth, maxHeight, callback); } /** * Sets if the channel logo exists. This method should be only called from * {@link ChannelDataManager}. */ void setChannelLogoExist(boolean exist) { mChannelLogoExist = exist; } /** * Returns if channel logo exists. */ public boolean channelLogoExists() { return mChannelLogoExist; } /** * Returns the type of app link for this channel. * It returns {@link #APP_LINK_TYPE_CHANNEL} if the channel has a non null app link text and * a valid app link intent, it returns {@link #APP_LINK_TYPE_APP} if the input service which * holds the channel has leanback launch intent, and it returns {@link #APP_LINK_TYPE_NONE} * otherwise. */ public int getAppLinkType(Context context) { if (mAppLinkType == APP_LINK_TYPE_NOT_SET) { initAppLinkTypeAndIntent(context); } return mAppLinkType; } /** * Returns the app link intent for this channel. * If the type of app link is {@link #APP_LINK_TYPE_NONE}, it returns {@code null}. */ public Intent getAppLinkIntent(Context context) { if (mAppLinkType == APP_LINK_TYPE_NOT_SET) { initAppLinkTypeAndIntent(context); } return mAppLinkIntent; } private void initAppLinkTypeAndIntent(Context context) { mAppLinkType = APP_LINK_TYPE_NONE; mAppLinkIntent = null; PackageManager pm = context.getPackageManager(); if (!TextUtils.isEmpty(mAppLinkText) && !TextUtils.isEmpty(mAppLinkIntentUri)) { try { Intent intent = Intent.parseUri(mAppLinkIntentUri, Intent.URI_INTENT_SCHEME); if (intent.resolveActivityInfo(pm, 0) != null) { mAppLinkIntent = intent; mAppLinkIntent.putExtra(TvCommonConstants.EXTRA_APP_LINK_CHANNEL_URI, getUri().toString()); mAppLinkType = APP_LINK_TYPE_CHANNEL; return; } else { Log.w(TAG, "No activity exists to handle : " + mAppLinkIntentUri); } } catch (URISyntaxException e) { Log.w(TAG, "Unable to set app link for " + mAppLinkIntentUri, e); // Do nothing. } } if (mPackageName.equals(context.getApplicationContext().getPackageName())) { return; } mAppLinkIntent = pm.getLeanbackLaunchIntentForPackage(mPackageName); if (mAppLinkIntent != null) { mAppLinkIntent.putExtra(TvCommonConstants.EXTRA_APP_LINK_CHANNEL_URI, getUri().toString()); mAppLinkType = APP_LINK_TYPE_APP; } } private String getImageUriString(int type) { switch (type) { case LOAD_IMAGE_TYPE_CHANNEL_LOGO: return TvContract.buildChannelLogoUri(mId).toString(); case LOAD_IMAGE_TYPE_APP_LINK_ICON: return mAppLinkIconUri; case LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART: return mAppLinkPosterArtUri; } return null; } public static class DefaultComparator implements Comparator { private final Context mContext; private final TvInputManagerHelper mInputManager; private final Map mInputIdToLabelMap = new HashMap<>(); private boolean mDetectDuplicatesEnabled; public DefaultComparator(Context context, TvInputManagerHelper inputManager) { mContext = context; mInputManager = inputManager; } public void setDetectDuplicatesEnabled(boolean detectDuplicatesEnabled) { mDetectDuplicatesEnabled = detectDuplicatesEnabled; } @Override public int compare(Channel lhs, Channel rhs) { if (lhs == rhs) { return 0; } // Put channels from OEM/SOC inputs first. boolean lhsIsPartner = mInputManager.isPartnerInput(lhs.getInputId()); boolean rhsIsPartner = mInputManager.isPartnerInput(rhs.getInputId()); if (lhsIsPartner != rhsIsPartner) { return lhsIsPartner ? -1 : 1; } // Compare the input labels. String lhsLabel = getInputLabelForChannel(lhs); String rhsLabel = getInputLabelForChannel(rhs); int result = lhsLabel == null ? (rhsLabel == null ? 0 : 1) : rhsLabel == null ? -1 : lhsLabel.compareTo(rhsLabel); if (result != 0) { return result; } // Compare the input IDs. The input IDs cannot be null. result = lhs.getInputId().compareTo(rhs.getInputId()); if (result != 0) { return result; } // Compare the channel numbers if both channels belong to the same input. result = ChannelNumber.compare(lhs.getDisplayNumber(), rhs.getDisplayNumber()); if (mDetectDuplicatesEnabled && result == 0) { Log.w(TAG, "Duplicate channels detected! - \"" + lhs.getDisplayText() + "\" and \"" + rhs.getDisplayText() + "\""); } return result; } @VisibleForTesting String getInputLabelForChannel(Channel channel) { String label = mInputIdToLabelMap.get(channel.getInputId()); if (label == null) { TvInputInfo info = mInputManager.getTvInputInfo(channel.getInputId()); if (info != null) { label = Utils.loadLabel(mContext, info); if (label != null) { mInputIdToLabelMap.put(channel.getInputId(), label); } } } return label; } } }