/* * 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.CommonConstants; import com.android.tv.common.util.CommonUtils; import com.android.tv.data.api.Channel; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import com.android.tv.util.images.ImageLoader; 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 ChannelImpl implements Channel { private static final String TAG = "ChannelImpl"; /** Compares the channel numbers of channels which belong to the same input. */ public static final Comparator CHANNEL_NUMBER_COMPARATOR = (Channel lhs, Channel rhs) -> ChannelNumber.compare(lhs.getDisplayNumber(), rhs.getDisplayNumber()); 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 ChannelImpl.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_SEARCHABLE, 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_NETWORK_AFFILIATION, TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG2, // Only used in bundled input }; /** * Creates {@code ChannelImpl} object from cursor. * *

The query that created the cursor MUST use {@link #PROJECTION} */ public static ChannelImpl fromCursor(Cursor cursor) { // Columns read must match the order of {@link #PROJECTION} ChannelImpl channel = new ChannelImpl(); 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.mSearchable = 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++); channel.mNetworkAffiliation = cursor.getString(index++); if (CommonUtils.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 mSearchable; 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 String mNetworkAffiliation; private int mAppLinkType; private String mLogoUri; private boolean mRecordingProhibited; private boolean mChannelLogoExist; private ChannelImpl() { // Do nothing. } @Override public long getId() { return mId; } @Override public Uri getUri() { if (isPassthrough()) { return TvContract.buildChannelUriForPassthroughInput(mInputId); } else { return TvContract.buildChannelUri(mId); } } @Override public String getPackageName() { return mPackageName; } @Override public String getInputId() { return mInputId; } @Override public String getType() { return mType; } @Override public String getDisplayNumber() { return mDisplayNumber; } @Override @Nullable public String getDisplayName() { return mDisplayName; } @Override public String getDescription() { return mDescription; } @Override public String getVideoFormat() { return mVideoFormat; } @Override public boolean isPassthrough() { return mIsPassthrough; } /** * Gets identification text for displaying or debugging. It's made from Channels' display number * plus their display name. */ @Override public String getDisplayText() { return TextUtils.isEmpty(mDisplayName) ? mDisplayNumber : mDisplayNumber + " " + mDisplayName; } @Override public String getAppLinkText() { return mAppLinkText; } @Override public int getAppLinkColor() { return mAppLinkColor; } @Override public String getAppLinkIconUri() { return mAppLinkIconUri; } @Override public String getAppLinkPosterArtUri() { return mAppLinkPosterArtUri; } @Override public String getAppLinkIntentUri() { return mAppLinkIntentUri; } @Override public String getNetworkAffiliation() { return mNetworkAffiliation; } /** Returns channel logo uri which is got from cloud, it's used only for ChannelLogoFetcher. */ @Override public String getLogoUri() { return mLogoUri; } @Override public boolean isRecordingProhibited() { return mRecordingProhibited; } /** Checks whether this channel is physical tuner channel or not. */ @Override 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 ChannelImpl)) { return false; } ChannelImpl other = (ChannelImpl) 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); } @Override public boolean isBrowsable() { return mBrowsable; } /** Checks whether this channel is searchable or not. */ @Override public boolean isSearchable() { return mSearchable; } @Override 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; } @Override public void setNetworkAffiliation(String networkAffiliation) { mNetworkAffiliation = networkAffiliation; } /** * 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. */ @Override public boolean hasSameReadOnlyInfo(Channel other) { return other != null && Objects.equals(mId, other.getId()) && Objects.equals(mPackageName, other.getPackageName()) && Objects.equals(mInputId, other.getInputId()) && Objects.equals(mType, other.getType()) && Objects.equals(mDisplayNumber, other.getDisplayNumber()) && Objects.equals(mDisplayName, other.getDisplayName()) && Objects.equals(mDescription, other.getDescription()) && Objects.equals(mVideoFormat, other.getVideoFormat()) && mIsPassthrough == other.isPassthrough() && Objects.equals(mAppLinkText, other.getAppLinkText()) && mAppLinkColor == other.getAppLinkColor() && Objects.equals(mAppLinkIconUri, other.getAppLinkIconUri()) && Objects.equals(mAppLinkPosterArtUri, other.getAppLinkPosterArtUri()) && Objects.equals(mAppLinkIntentUri, other.getAppLinkIntentUri()) && Objects.equals(mRecordingProhibited, other.isRecordingProhibited()); } @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 + ", searchable=" + mSearchable + ", locked=" + mLocked + ", appLinkText=" + mAppLinkText + ", recordingProhibited=" + mRecordingProhibited + "}"; } @Override public void copyFrom(Channel channel) { if (channel instanceof ChannelImpl) { copyFrom((ChannelImpl) channel); } else { // copy what we can mId = channel.getId(); mPackageName = channel.getPackageName(); mInputId = channel.getInputId(); mType = channel.getType(); mDisplayNumber = channel.getDisplayNumber(); mDisplayName = channel.getDisplayName(); mDescription = channel.getDescription(); mVideoFormat = channel.getVideoFormat(); mIsPassthrough = channel.isPassthrough(); mBrowsable = channel.isBrowsable(); mSearchable = channel.isSearchable(); mLocked = channel.isLocked(); mAppLinkText = channel.getAppLinkText(); mAppLinkColor = channel.getAppLinkColor(); mAppLinkIconUri = channel.getAppLinkIconUri(); mAppLinkPosterArtUri = channel.getAppLinkPosterArtUri(); mAppLinkIntentUri = channel.getAppLinkIntentUri(); mNetworkAffiliation = channel.getNetworkAffiliation(); mRecordingProhibited = channel.isRecordingProhibited(); mChannelLogoExist = channel.channelLogoExists(); mNetworkAffiliation = channel.getNetworkAffiliation(); } } @SuppressWarnings("ReferenceEquality") public void copyFrom(ChannelImpl channel) { ChannelImpl other = (ChannelImpl) channel; 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; mSearchable = other.mSearchable; mLocked = other.mLocked; mAppLinkText = other.mAppLinkText; mAppLinkColor = other.mAppLinkColor; mAppLinkIconUri = other.mAppLinkIconUri; mAppLinkPosterArtUri = other.mAppLinkPosterArtUri; mAppLinkIntentUri = other.mAppLinkIntentUri; mNetworkAffiliation = channel.mNetworkAffiliation; mAppLinkIntent = other.mAppLinkIntent; mAppLinkType = other.mAppLinkType; mRecordingProhibited = other.mRecordingProhibited; mChannelLogoExist = other.mChannelLogoExist; } /** Creates a channel for a passthrough TV input. */ public static ChannelImpl 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 ChannelImpl 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.getId() != INVALID_ID || channel.isPassthrough()); } /** * Builder class for {@code ChannelImpl}. Suppress using this outside of ChannelDataManager so * Channels could be managed by ChannelDataManager. */ public static final class Builder { private final ChannelImpl mChannel; public Builder() { mChannel = new ChannelImpl(); // 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; mChannel.mSearchable = true; } public Builder(Channel other) { mChannel = new ChannelImpl(); 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 setSearchable(boolean searchable) { mChannel.mSearchable = searchable; 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; } @VisibleForTesting public Builder setNetworkAffiliation(String networkAffiliation) { mChannel.mNetworkAffiliation = networkAffiliation; 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 ChannelImpl build() { ChannelImpl channel = new ChannelImpl(); 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 * Channel#LOAD_IMAGE_TYPE_CHANNEL_LOGO}, {@link Channel#LOAD_IMAGE_TYPE_APP_LINK_ICON}, or * {@link Channel#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}. */ @Override public 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 * Channel#APP_LINK_TYPE_CHANNEL} if the channel has a non null app link text and a valid app * link intent, it returns {@link Channel#APP_LINK_TYPE_APP} if the input service which holds * the channel has leanback launch intent, and it returns {@link Channel#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 * Channel#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( CommonConstants.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( CommonConstants.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; } /** * Default Channel ordering. * *

Ordering *

  • {@link TvInputManagerHelper#isPartnerInput(String)} *
  • {@link #getInputLabelForChannel(Channel)} *
  • {@link #getInputId()} *
  • {@link ChannelNumber#compare(String, String)} *
  • * */ 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; } @SuppressWarnings("ReferenceEquality") @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; } } }