diff options
Diffstat (limited to 'src/com/android/tv/data')
-rw-r--r-- | src/com/android/tv/data/BaseProgram.java | 39 | ||||
-rw-r--r-- | src/com/android/tv/data/Channel.java | 114 | ||||
-rw-r--r-- | src/com/android/tv/data/ChannelDataManager.java | 200 | ||||
-rw-r--r-- | src/com/android/tv/data/ChannelLogoFetcher.java | 307 | ||||
-rw-r--r-- | src/com/android/tv/data/ChannelNumber.java | 71 | ||||
-rw-r--r-- | src/com/android/tv/data/InternalDataUtils.java | 2 | ||||
-rw-r--r-- | src/com/android/tv/data/PreviewDataManager.java | 636 | ||||
-rw-r--r-- | src/com/android/tv/data/PreviewProgramContent.java | 259 | ||||
-rw-r--r-- | src/com/android/tv/data/Program.java | 140 | ||||
-rw-r--r-- | src/com/android/tv/data/ProgramDataManager.java | 76 | ||||
-rw-r--r-- | src/com/android/tv/data/StreamInfo.java | 4 | ||||
-rw-r--r-- | src/com/android/tv/data/WatchedHistoryManager.java | 161 | ||||
-rw-r--r-- | src/com/android/tv/data/epg/EpgFetchHelper.java | 233 | ||||
-rw-r--r-- | src/com/android/tv/data/epg/EpgFetcher.java | 988 | ||||
-rw-r--r-- | src/com/android/tv/data/epg/EpgReader.java | 45 | ||||
-rw-r--r-- | src/com/android/tv/data/epg/StubEpgReader.java | 37 |
16 files changed, 2360 insertions, 952 deletions
diff --git a/src/com/android/tv/data/BaseProgram.java b/src/com/android/tv/data/BaseProgram.java index f420de02..4e36c80a 100644 --- a/src/com/android/tv/data/BaseProgram.java +++ b/src/com/android/tv/data/BaseProgram.java @@ -17,12 +17,17 @@ package com.android.tv.data; import android.content.Context; +import android.media.tv.TvContentRating; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.tv.R; import java.util.Comparator; /** * Base class for {@link com.android.tv.data.Program} and - * {@link com.android.tv.dvr.RecordedProgram}. + * {@link com.android.tv.dvr.data.RecordedProgram}. */ public abstract class BaseProgram { /** @@ -94,14 +99,29 @@ public abstract class BaseProgram { abstract public String getTitle(); /** - * Returns the program's title withe its season and episode number. + * Returns the episode title. */ - abstract public String getTitleWithEpisodeNumber(Context context); + abstract public String getEpisodeTitle(); /** * Returns the displayed title of the program episode. */ - abstract public String getEpisodeDisplayTitle(Context context); + public String getEpisodeDisplayTitle(Context context) { + if (!TextUtils.isEmpty(getEpisodeNumber())) { + String episodeTitle = getEpisodeTitle() == null ? "" : getEpisodeTitle(); + if (TextUtils.equals(getSeasonNumber(), "0")) { + // Do not show "S0: ". + return String.format(context.getResources().getString( + R.string.display_episode_title_format_no_season_number), + getEpisodeNumber(), episodeTitle); + } else { + return String.format(context.getResources().getString( + R.string.display_episode_title_format), + getSeasonNumber(), getEpisodeNumber(), episodeTitle); + } + } + return getEpisodeTitle(); + } /** * Returns the description of the program. @@ -158,6 +178,10 @@ public abstract class BaseProgram { */ abstract public int[] getCanonicalGenreIds(); + /** Returns the array of content ratings. */ + @Nullable + abstract public TvContentRating[] getContentRatings(); + /** * Returns channel's ID of the program. */ @@ -169,6 +193,13 @@ public abstract class BaseProgram { abstract public boolean isValid(); /** + * Checks whether the program is episodic or not. + */ + public boolean isEpisodic() { + return getSeriesId() != null; + } + + /** * Generates the series ID for the other inputs than the tuner TV input. */ public static String generateSeriesId(String packageName, String title) { diff --git a/src/com/android/tv/data/Channel.java b/src/com/android/tv/data/Channel.java index 30f84236..4a391ae7 100644 --- a/src/com/android/tv/data/Channel.java +++ b/src/com/android/tv/data/Channel.java @@ -52,6 +52,16 @@ public final class Channel { 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> CHANNEL_NUMBER_COMPARATOR = new Comparator<Channel>() { + @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. */ @@ -81,15 +91,22 @@ public final class Channel { 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_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. * * <p>The query that created the cursor MUST use {@link #PROJECTION} @@ -103,28 +120,41 @@ public final class Channel { channel.mPackageName = Utils.intern(cursor.getString(index++)); channel.mInputId = Utils.intern(cursor.getString(index++)); channel.mType = Utils.intern(cursor.getString(index++)); - channel.mDisplayNumber = 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++); + if (Utils.isBundledInput(channel.mInputId)) { + channel.mRecordingProhibited = cursor.getInt(index++) != 0; + } return channel; } /** - * Creates a {@link Channel} object from the DVR database. + * Replaces the channel number separator with dash('-'). */ - public static Channel fromDvrCursor(Cursor c) { - Channel channel = new Channel(); - int index = -1; - channel.mDvrId = c.getLong(++index); - return channel; + 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. */ @@ -138,6 +168,7 @@ public final class Channel { private String mDescription; private String mVideoFormat; private boolean mBrowsable; + private boolean mSearchable; private boolean mLocked; private boolean mIsPassthrough; private String mAppLinkText; @@ -147,8 +178,10 @@ public final class Channel { private String mAppLinkIntentUri; private Intent mAppLinkIntent; private int mAppLinkType; + private String mLogoUri; + private boolean mRecordingProhibited; - private long mDvrId; + private boolean mChannelLogoExist; private Channel() { // Do nothing. @@ -187,7 +220,6 @@ public final class Channel { return mDisplayName; } - @VisibleForTesting public String getDescription() { return mDescription; } @@ -230,10 +262,14 @@ public final class Channel { } /** - * Returns an ID in DVR database. + * Returns channel logo uri which is got from cloud, it's used only for ChannelLogoFetcher. */ - public long getDvrId() { - return mDvrId; + public String getLogoUri() { + return mLogoUri; + } + + public boolean isRecordingProhibited() { + return mRecordingProhibited; } /** @@ -266,6 +302,11 @@ public final class Channel { return mBrowsable; } + /** Checks whether this channel is searchable or not. */ + public boolean isSearchable() { + return mSearchable; + } + public boolean isLocked() { return mLocked; } @@ -279,6 +320,13 @@ public final class Channel { } /** + * 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. @@ -298,7 +346,8 @@ public final class Channel { && mAppLinkColor == other.mAppLinkColor && Objects.equals(mAppLinkIconUri, other.mAppLinkIconUri) && Objects.equals(mAppLinkPosterArtUri, other.mAppLinkPosterArtUri) - && Objects.equals(mAppLinkIntentUri, other.mAppLinkIntentUri); + && Objects.equals(mAppLinkIntentUri, other.mAppLinkIntentUri) + && Objects.equals(mRecordingProhibited, other.mRecordingProhibited); } @Override @@ -314,8 +363,10 @@ public final class Channel { + ", videoFormat=" + mVideoFormat + ", isPassthrough=" + mIsPassthrough + ", browsable=" + mBrowsable + + ", searchable=" + mSearchable + ", locked=" + mLocked - + ", appLinkText=" + mAppLinkText + "}"; + + ", appLinkText=" + mAppLinkText + + ", recordingProhibited=" + mRecordingProhibited + "}"; } void copyFrom(Channel other) { @@ -332,6 +383,7 @@ public final class Channel { mVideoFormat = other.mVideoFormat; mIsPassthrough = other.mIsPassthrough; mBrowsable = other.mBrowsable; + mSearchable = other.mSearchable; mLocked = other.mLocked; mAppLinkText = other.mAppLinkText; mAppLinkColor = other.mAppLinkColor; @@ -340,6 +392,8 @@ public final class Channel { mAppLinkIntentUri = other.mAppLinkIntentUri; mAppLinkIntent = other.mAppLinkIntent; mAppLinkType = other.mAppLinkType; + mRecordingProhibited = other.mRecordingProhibited; + mChannelLogoExist = other.mChannelLogoExist; } /** @@ -389,8 +443,7 @@ public final class Channel { mChannel.mDisplayName = "name"; mChannel.mDescription = "description"; mChannel.mBrowsable = true; - mChannel.mLocked = false; - mChannel.mIsPassthrough = false; + mChannel.mSearchable = true; } public Builder(Channel other) { @@ -422,7 +475,7 @@ public final class Channel { @VisibleForTesting public Builder setDisplayNumber(String displayNumber) { - mChannel.mDisplayNumber = displayNumber; + mChannel.mDisplayNumber = normalizeDisplayNumber(displayNumber); return this; } @@ -448,6 +501,11 @@ public final class Channel { return this; } + public Builder setSearchable(boolean searchable) { + mChannel.mSearchable = searchable; + return this; + } + public Builder setLocked(boolean locked) { mChannel.mLocked = locked; return this; @@ -485,6 +543,11 @@ public final class Channel { return this; } + public Builder setRecordingProhibited(boolean recordingProhibited) { + mChannel.mRecordingProhibited = recordingProhibited; + return this; + } + public Channel build() { Channel channel = new Channel(); channel.copyFrom(mChannel); @@ -524,6 +587,21 @@ public final class Channel { } /** + * 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 @@ -655,4 +733,4 @@ public final class Channel { return label; } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/data/ChannelDataManager.java b/src/com/android/tv/data/ChannelDataManager.java index 6f9ea6d7..6f93fbd1 100644 --- a/src/com/android/tv/data/ChannelDataManager.java +++ b/src/com/android/tv/data/ChannelDataManager.java @@ -21,13 +21,17 @@ import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; +import android.content.res.AssetFileDescriptor; import android.database.ContentObserver; +import android.database.sqlite.SQLiteException; import android.media.tv.TvContract; import android.media.tv.TvContract.Channels; import android.media.tv.TvInputManager.TvInputCallback; +import android.os.AsyncTask; import android.os.Handler; import android.os.Looper; import android.os.Message; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; @@ -43,6 +47,7 @@ import com.android.tv.util.PermissionUtils; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -59,7 +64,7 @@ import java.util.concurrent.CopyOnWriteArraySet; * This class is not thread-safe and under an assumption that its public methods are called in * only the main thread. */ -@MainThread +@AnyThread public class ChannelDataManager { private static final String TAG = "ChannelDataManager"; private static final boolean DEBUG = false; @@ -74,10 +79,10 @@ public class ChannelDataManager { private final List<Runnable> mPostRunnablesAfterChannelUpdate = new ArrayList<>(); private final Set<Listener> mListeners = new CopyOnWriteArraySet<>(); - private final Map<Long, ChannelWrapper> mChannelWrapperMap = new HashMap<>(); - private final Map<String, MutableInt> mChannelCountMap = new HashMap<>(); + // Use container class to support multi-thread safety. This value can be set only on the main + // thread. + volatile private UnmodifiableChannelData mData = new UnmodifiableChannelData(); private final Channel.DefaultComparator mChannelComparator; - private final List<Channel> mChannels = new ArrayList<>(); private final Handler mHandler; private final Set<Long> mBrowsableUpdateChannelIds = new HashSet<>(); @@ -92,15 +97,17 @@ public class ChannelDataManager { @Override public void onInputAdded(String inputId) { boolean channelAdded = false; - for (ChannelWrapper channel : mChannelWrapperMap.values()) { + ChannelData data = new ChannelData(mData); + for (ChannelWrapper channel : mData.channelWrapperMap.values()) { if (channel.mChannel.getInputId().equals(inputId)) { channel.mInputRemoved = false; - addChannel(channel.mChannel); + addChannel(data, channel.mChannel); channelAdded = true; } } if (channelAdded) { - Collections.sort(mChannels, mChannelComparator); + Collections.sort(data.channels, mChannelComparator); + mData = new UnmodifiableChannelData(data); notifyChannelListUpdated(); } } @@ -109,7 +116,7 @@ public class ChannelDataManager { public void onInputRemoved(String inputId) { boolean channelRemoved = false; ArrayList<ChannelWrapper> removedChannels = new ArrayList<>(); - for (ChannelWrapper channel : mChannelWrapperMap.values()) { + for (ChannelWrapper channel : mData.channelWrapperMap.values()) { if (channel.mChannel.getInputId().equals(inputId)) { channel.mInputRemoved = true; channelRemoved = true; @@ -117,13 +124,15 @@ public class ChannelDataManager { } } if (channelRemoved) { - clearChannels(); - for (ChannelWrapper channelWrapper : mChannelWrapperMap.values()) { + ChannelData data = new ChannelData(); + data.channelWrapperMap.putAll(mData.channelWrapperMap); + for (ChannelWrapper channelWrapper : data.channelWrapperMap.values()) { if (!channelWrapper.mInputRemoved) { - addChannel(channelWrapper.mChannel); + addChannel(data, channelWrapper.mChannel); } } - Collections.sort(mChannels, mChannelComparator); + Collections.sort(data.channels, mChannelComparator); + mData = new UnmodifiableChannelData(data); notifyChannelListUpdated(); for (ChannelWrapper channel : removedChannels) { channel.notifyChannelRemoved(); @@ -132,10 +141,12 @@ public class ChannelDataManager { } }; + @MainThread public ChannelDataManager(Context context, TvInputManagerHelper inputManager) { this(context, inputManager, context.getContentResolver()); } + @MainThread @VisibleForTesting ChannelDataManager(Context context, TvInputManagerHelper inputManager, ContentResolver contentResolver) { @@ -167,6 +178,7 @@ public class ChannelDataManager { /** * Starts the manager. If data is ready, {@link Listener#onLoadFinished()} will be called. */ + @MainThread public void start() { if (mStarted) { return; @@ -184,6 +196,7 @@ public class ChannelDataManager { * Stops the manager. It clears manager states and runs pending DB operations. Added listeners * aren't automatically removed by this method. */ + @MainThread @VisibleForTesting public void stop() { if (!mStarted) { @@ -192,12 +205,10 @@ public class ChannelDataManager { mStarted = false; mDbLoadFinished = false; - ChannelLogoFetcher.stopFetchingChannelLogos(); mInputManager.removeCallback(mTvInputCallback); mContentResolver.unregisterContentObserver(mChannelObserver); mHandler.removeCallbacksAndMessages(null); - mChannelWrapperMap.clear(); clearChannels(); mPostRunnablesAfterChannelUpdate.clear(); if (mChannelsUpdateTask != null) { @@ -233,7 +244,7 @@ public class ChannelDataManager { * Adds a {@link ChannelListener} for a specific channel with the channel ID {@code channelId}. */ public void addChannelListener(Long channelId, ChannelListener listener) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null) { return; } @@ -245,7 +256,7 @@ public class ChannelDataManager { * {@code channelId}. */ public void removeChannelListener(Long channelId, ChannelListener listener) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null) { return; } @@ -263,14 +274,14 @@ public class ChannelDataManager { * Returns the number of channels. */ public int getChannelCount() { - return mChannels.size(); + return mData.channels.size(); } /** * Returns a list of channels. */ public List<Channel> getChannelList() { - return Collections.unmodifiableList(mChannels); + return new ArrayList<>(mData.channels); } /** @@ -278,7 +289,7 @@ public class ChannelDataManager { */ public List<Channel> getBrowsableChannelList() { List<Channel> channels = new ArrayList<>(); - for (Channel channel : mChannels) { + for (Channel channel : mData.channels) { if (channel.isBrowsable()) { channels.add(channel); } @@ -292,7 +303,7 @@ public class ChannelDataManager { * @param inputId The ID of the input. */ public int getChannelCountForInput(String inputId) { - MutableInt count = mChannelCountMap.get(inputId); + MutableInt count = mData.channelCountMap.get(inputId); return count == null ? 0 : count.value; } @@ -303,17 +314,14 @@ public class ChannelDataManager { * In that case this method is used to check if the channel exists in the DB. */ public boolean doesChannelExistInDb(long channelId) { - return mChannelWrapperMap.get(channelId) != null; + return mData.channelWrapperMap.get(channelId) != null; } /** * Returns true if and only if there exists at least one channel and all channels are hidden. */ public boolean areAllChannelsHidden() { - if (mChannels.isEmpty()) { - return false; - } - for (Channel channel : mChannels) { + for (Channel channel : mData.channels) { if (channel.isBrowsable()) { return false; } @@ -325,7 +333,7 @@ public class ChannelDataManager { * Gets the channel with the channel ID {@code channelId}. */ public Channel getChannel(Long channelId) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null || channelWrapper.mInputRemoved) { return null; } @@ -349,7 +357,7 @@ public class ChannelDataManager { */ public void updateBrowsable(Long channelId, boolean browsable, boolean skipNotifyChannelBrowsableChanged) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null) { return; } @@ -407,7 +415,7 @@ public class ChannelDataManager { * The value change will be applied to DB when applyPendingDbOperation is called. */ public void updateLocked(Long channelId, boolean locked) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null) { return; } @@ -427,10 +435,11 @@ public class ChannelDataManager { * to DB. */ public void applyUpdatedValuesToDb() { + ChannelData data = mData; ArrayList<Long> browsableIds = new ArrayList<>(); ArrayList<Long> unbrowsableIds = new ArrayList<>(); for (Long id : mBrowsableUpdateChannelIds) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(id); + ChannelWrapper channelWrapper = data.channelWrapperMap.get(id); if (channelWrapper == null) { continue; } @@ -452,10 +461,10 @@ public class ChannelDataManager { } editor.apply(); } else { - if (browsableIds.size() != 0) { + if (!browsableIds.isEmpty()) { updateOneColumnValue(column, 1, browsableIds); } - if (unbrowsableIds.size() != 0) { + if (!unbrowsableIds.isEmpty()) { updateOneColumnValue(column, 0, unbrowsableIds); } } @@ -464,7 +473,7 @@ public class ChannelDataManager { ArrayList<Long> lockedIds = new ArrayList<>(); ArrayList<Long> unlockedIds = new ArrayList<>(); for (Long id : mLockedUpdateChannelIds) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(id); + ChannelWrapper channelWrapper = data.channelWrapperMap.get(id); if (channelWrapper == null) { continue; } @@ -476,10 +485,10 @@ public class ChannelDataManager { channelWrapper.mLockedInDb = channelWrapper.mChannel.isLocked(); } column = TvContract.Channels.COLUMN_LOCKED; - if (lockedIds.size() != 0) { + if (!lockedIds.isEmpty()) { updateOneColumnValue(column, 1, lockedIds); } - if (unlockedIds.size() != 0) { + if (!unlockedIds.isEmpty()) { updateOneColumnValue(column, 0, unlockedIds); } mLockedUpdateChannelIds.clear(); @@ -492,22 +501,24 @@ public class ChannelDataManager { } } - private void addChannel(Channel channel) { - mChannels.add(channel); + @MainThread + private void addChannel(ChannelData data, Channel channel) { + data.channels.add(channel); String inputId = channel.getInputId(); - MutableInt count = mChannelCountMap.get(inputId); + MutableInt count = data.channelCountMap.get(inputId); if (count == null) { - mChannelCountMap.put(inputId, new MutableInt(1)); + data.channelCountMap.put(inputId, new MutableInt(1)); } else { count.value++; } } + @MainThread private void clearChannels() { - mChannels.clear(); - mChannelCountMap.clear(); + mData = new UnmodifiableChannelData(); } + @MainThread private void handleUpdateChannels() { if (mChannelsUpdateTask != null) { mChannelsUpdateTask.cancel(true); @@ -525,6 +536,9 @@ public class ChannelDataManager { } } + /** + * A listener for ChannelDataManager. The callbacks are called on the main thread. + */ public interface Listener { /** * Called when data load is finished. @@ -543,6 +557,9 @@ public class ChannelDataManager { void onChannelBrowsableChanged(); } + /** + * A listener for individual channel change. The callbacks are called on the main thread. + */ public interface ChannelListener { /** * Called when the channel has been removed in DB. @@ -590,9 +607,36 @@ public class ChannelDataManager { } } + private class CheckChannelLogoExistTask extends AsyncTask<Void, Void, Boolean> { + private final Channel mChannel; + + CheckChannelLogoExistTask(Channel channel) { + mChannel = channel; + } + + @Override + protected Boolean doInBackground(Void... params) { + try (AssetFileDescriptor f = mContext.getContentResolver().openAssetFileDescriptor( + TvContract.buildChannelLogoUri(mChannel.getId()), "r")) { + return true; + } catch (SQLiteException | IOException | NullPointerException e) { + // File not found or asset file not found. + } + return false; + } + + @Override + protected void onPostExecute(Boolean result) { + ChannelWrapper wrapper = mData.channelWrapperMap.get(mChannel.getId()); + if (wrapper != null) { + wrapper.mChannel.setChannelLogoExist(result); + } + } + } + private final class QueryAllChannelsTask extends AsyncDbTask.AsyncChannelQueryTask { - public QueryAllChannelsTask(ContentResolver contentResolver) { + QueryAllChannelsTask(ContentResolver contentResolver) { super(contentResolver); } @@ -603,7 +647,9 @@ public class ChannelDataManager { if (DEBUG) Log.e(TAG, "onPostExecute with null channels"); return; } - Set<Long> removedChannelIds = new HashSet<>(mChannelWrapperMap.keySet()); + ChannelData data = new ChannelData(); + data.channelWrapperMap.putAll(mData.channelWrapperMap); + Set<Long> removedChannelIds = new HashSet<>(data.channelWrapperMap.keySet()); List<ChannelWrapper> removedChannelWrappers = new ArrayList<>(); List<ChannelWrapper> updatedChannelWrappers = new ArrayList<>(); @@ -625,13 +671,15 @@ public class ChannelDataManager { boolean newlyAdded = !removedChannelIds.remove(channelId); ChannelWrapper channelWrapper; if (newlyAdded) { + new CheckChannelLogoExistTask(channel) + .executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); channelWrapper = new ChannelWrapper(channel); - mChannelWrapperMap.put(channel.getId(), channelWrapper); + data.channelWrapperMap.put(channel.getId(), channelWrapper); if (!channelWrapper.mInputRemoved) { channelAdded = true; } } else { - channelWrapper = mChannelWrapperMap.get(channelId); + channelWrapper = data.channelWrapperMap.get(channelId); if (!channelWrapper.mChannel.hasSameReadOnlyInfo(channel)) { // Channel data updated Channel oldChannel = channelWrapper.mChannel; @@ -640,9 +688,9 @@ public class ChannelDataManager { // {@link #applyUpdatedValuesToDb} is called. Therefore, the value // between DB and ChannelDataManager could be different for a while. // Therefore, we'll keep the values in ChannelDataManager. - channelWrapper.mChannel.copyFrom(channel); channel.setBrowsable(oldChannel.isBrowsable()); channel.setLocked(oldChannel.isLocked()); + channelWrapper.mChannel.copyFrom(channel); if (!channelWrapper.mInputRemoved) { channelUpdated = true; updatedChannelWrappers.add(channelWrapper); @@ -663,19 +711,19 @@ public class ChannelDataManager { } for (long id : removedChannelIds) { - ChannelWrapper channelWrapper = mChannelWrapperMap.remove(id); + ChannelWrapper channelWrapper = data.channelWrapperMap.remove(id); if (!channelWrapper.mInputRemoved) { channelRemoved = true; removedChannelWrappers.add(channelWrapper); } } - clearChannels(); - for (ChannelWrapper channelWrapper : mChannelWrapperMap.values()) { + for (ChannelWrapper channelWrapper : data.channelWrapperMap.values()) { if (!channelWrapper.mInputRemoved) { - addChannel(channelWrapper.mChannel); + addChannel(data, channelWrapper.mChannel); } } - Collections.sort(mChannels, mChannelComparator); + Collections.sort(data.channels, mChannelComparator); + mData = new UnmodifiableChannelData(data); if (!mDbLoadFinished) { mDbLoadFinished = true; @@ -693,7 +741,6 @@ public class ChannelDataManager { r.run(); } mPostRunnablesAfterChannelUpdate.clear(); - ChannelLogoFetcher.startFetchingChannelLogos(mContext); } } @@ -705,10 +752,9 @@ public class ChannelDataManager { private void updateOneColumnValue( final String columnName, final int columnValue, final List<Long> ids) { if (!PermissionUtils.hasAccessAllEpg(mContext)) { - // TODO: support this feature for non-system LC app. b/23939816 return; } - AsyncDbTask.execute(new Runnable() { + AsyncDbTask.executeOnDbThread(new Runnable() { @Override public void run() { String selection = Utils.buildSelectionForIds(Channels._ID, ids); @@ -723,6 +769,7 @@ public class ChannelDataManager { return channel.getInputId() + "|" + channel.getId(); } + @MainThread private static class ChannelDataManagerHandler extends WeakHandler<ChannelDataManager> { public ChannelDataManagerHandler(ChannelDataManager channelDataManager) { super(Looper.getMainLooper(), channelDataManager); @@ -735,4 +782,51 @@ public class ChannelDataManager { } } } + + /** + * Container class which includes channel data that needs to be synced. This class is + * modifiable and used for changing channel data. + * e.g. TvInputCallback, or AsyncDbTask.onPostExecute. + */ + @MainThread + private static class ChannelData { + final Map<Long, ChannelWrapper> channelWrapperMap; + final Map<String, MutableInt> channelCountMap; + final List<Channel> channels; + + ChannelData() { + channelWrapperMap = new HashMap<>(); + channelCountMap = new HashMap<>(); + channels = new ArrayList<>(); + } + + ChannelData(ChannelData data) { + channelWrapperMap = new HashMap<>(data.channelWrapperMap); + channelCountMap = new HashMap<>(data.channelCountMap); + channels = new ArrayList<>(data.channels); + } + + ChannelData(Map<Long, ChannelWrapper> channelWrapperMap, + Map<String, MutableInt> channelCountMap, List<Channel> channels) { + this.channelWrapperMap = channelWrapperMap; + this.channelCountMap = channelCountMap; + this.channels = channels; + } + } + + /** Unmodifiable channel data. */ + @MainThread + private static class UnmodifiableChannelData extends ChannelData { + UnmodifiableChannelData() { + super(Collections.unmodifiableMap(new HashMap<>()), + Collections.unmodifiableMap(new HashMap<>()), + Collections.unmodifiableList(new ArrayList<>())); + } + + UnmodifiableChannelData(ChannelData data) { + super(Collections.unmodifiableMap(data.channelWrapperMap), + Collections.unmodifiableMap(data.channelCountMap), + Collections.unmodifiableList(data.channels)); + } + } } diff --git a/src/com/android/tv/data/ChannelLogoFetcher.java b/src/com/android/tv/data/ChannelLogoFetcher.java index 5a549f83..132cab7a 100644 --- a/src/com/android/tv/data/ChannelLogoFetcher.java +++ b/src/com/android/tv/data/ChannelLogoFetcher.java @@ -16,160 +16,74 @@ package com.android.tv.data; +import android.content.ContentProviderOperation; import android.content.Context; -import android.database.Cursor; +import android.content.OperationApplicationException; +import android.content.SharedPreferences; import android.graphics.Bitmap.CompressFormat; import android.media.tv.TvContract; -import android.media.tv.TvContract.Channels; import android.net.Uri; import android.os.AsyncTask; -import android.support.annotation.WorkerThread; +import android.os.RemoteException; +import android.support.annotation.MainThread; import android.text.TextUtils; import android.util.Log; -import com.android.tv.util.AsyncDbTask; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.util.BitmapUtils; import com.android.tv.util.BitmapUtils.ScaledBitmapInfo; import com.android.tv.util.PermissionUtils; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; import java.io.OutputStream; import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Set; +import java.util.List; /** - * Utility class for TMS data. - * This class is thread safe. + * Fetches channel logos from the cloud into the database. It's for the channels which have no logos + * or need update logos. This class is thread safe. */ public class ChannelLogoFetcher { private static final String TAG = "ChannelLogoFetcher"; private static final boolean DEBUG = false; - /** - * The name of the file which contains the TMS data. - * The file has multiple records and each of them is a string separated by '|' like - * STATION_NAME|SHORT_NAME|CALL_SIGN|LOGO_URI. - */ - private static final String TMS_US_TABLE_FILE = "tms_us.table"; - private static final String TMS_KR_TABLE_FILE = "tms_kr.table"; - private static final String FIELD_SEPARATOR = "\\|"; - private static final String NAME_SEPARATOR_FOR_TMS = "\\(|\\)|\\{|\\}|\\[|\\]"; - private static final String NAME_SEPARATOR_FOR_DB = "\\W"; - private static final int INDEX_NAME = 0; - private static final int INDEX_SHORT_NAME = 1; - private static final int INDEX_CALL_SIGN = 2; - private static final int INDEX_LOGO_URI = 3; - - private static final String COLUMN_CHANNEL_LOGO = "logo"; + private static final String PREF_KEY_IS_FIRST_TIME_FETCH_CHANNEL_LOGO = + "is_first_time_fetch_channel_logo"; - private static final Object sLock = new Object(); - private static final Set<Long> sChannelIdBlackListSet = new HashSet<>(); - private static LoadChannelTask sQueryTask; private static FetchLogoTask sFetchTask; /** - * Fetch the channel logos from TMS data and insert them into TvProvider. + * Fetches the channel logos from the cloud data and insert them into TvProvider. * The previous task is canceled and a new task starts. */ - public static void startFetchingChannelLogos(Context context) { + @MainThread + public static void startFetchingChannelLogos( + Context context, List<Channel> channels) { if (!PermissionUtils.hasAccessAllEpg(context)) { // TODO: support this feature for non-system LC app. b/23939816 return; } - synchronized (sLock) { - stopFetchingChannelLogos(); - if (DEBUG) Log.d(TAG, "Request to start fetching logos."); - sQueryTask = new LoadChannelTask(context); - sQueryTask.executeOnDbThread(); + if (sFetchTask != null) { + sFetchTask.cancel(true); + sFetchTask = null; } - } - - /** - * Stops the current fetching tasks. This can be called when the Activity pauses. - */ - public static void stopFetchingChannelLogos() { - synchronized (sLock) { - if (DEBUG) Log.d(TAG, "Request to stop fetching logos."); - if (sQueryTask != null) { - sQueryTask.cancel(true); - sQueryTask = null; - } - if (sFetchTask != null) { - sFetchTask.cancel(true); - sFetchTask = null; - } + if (DEBUG) Log.d(TAG, "Request to start fetching logos."); + if (channels == null || channels.isEmpty()) { + return; } + sFetchTask = new FetchLogoTask(context.getApplicationContext(), channels); + sFetchTask.execute(); } private ChannelLogoFetcher() { } - private static final class LoadChannelTask extends AsyncDbTask<Void, Void, List<Channel>> { - private final Context mContext; - - public LoadChannelTask(Context context) { - mContext = context; - } - - @Override - protected List<Channel> doInBackground(Void... arg) { - // Load channels which doesn't have channel logos. - if (DEBUG) Log.d(TAG, "Starts loading the channels from DB"); - String[] projection = - new String[] { Channels._ID, Channels.COLUMN_DISPLAY_NAME }; - String selection = COLUMN_CHANNEL_LOGO + " IS NULL AND " - + Channels.COLUMN_PACKAGE_NAME + "=?"; - String[] selectionArgs = new String[] { mContext.getPackageName() }; - try (Cursor c = mContext.getContentResolver().query(Channels.CONTENT_URI, - projection, selection, selectionArgs, null)) { - if (c == null) { - Log.e(TAG, "Query returns null cursor", new RuntimeException()); - return null; - } - List<Channel> channels = new ArrayList<>(); - while (!isCancelled() && c.moveToNext()) { - long channelId = c.getLong(0); - if (sChannelIdBlackListSet.contains(channelId)) { - continue; - } - channels.add(new Channel.Builder().setId(c.getLong(0)) - .setDisplayName(c.getString(1).toUpperCase(Locale.getDefault())) - .build()); - } - return channels; - } - } - - @Override - protected void onPostExecute(List<Channel> channels) { - synchronized (sLock) { - if (DEBUG) { - int count = channels == null ? 0 : channels.size(); - Log.d(TAG, count + " channels are loaded"); - } - if (sQueryTask == this) { - sQueryTask = null; - if (channels != null && !channels.isEmpty()) { - sFetchTask = new FetchLogoTask(mContext, channels); - sFetchTask.execute(); - } - } - } - } - } - private static final class FetchLogoTask extends AsyncTask<Void, Void, Void> { private final Context mContext; private final List<Channel> mChannels; - public FetchLogoTask(Context context, List<Channel> channels) { + private FetchLogoTask(Context context, List<Channel> channels) { mContext = context; mChannels = channels; } @@ -180,83 +94,53 @@ public class ChannelLogoFetcher { if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); return null; } - // Load the TMS table data. - if (DEBUG) Log.d(TAG, "Loads TMS data"); - Map<String, String> channelNameLogoUriMap = new HashMap<>(); - try { - channelNameLogoUriMap.putAll(readTmsFile(mContext, TMS_US_TABLE_FILE)); - if (isCancelled()) { - if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); - return null; + List<Channel> channelsToUpdate = new ArrayList<>(); + List<Channel> channelsToRemove = new ArrayList<>(); + // Updates or removes the logo by comparing the logo uri which is got from the cloud + // and the stored one. And we assume that the data got form the cloud is 100% + // correct and completed. + SharedPreferences sharedPreferences = + mContext.getSharedPreferences( + SharedPreferencesUtils.SHARED_PREF_CHANNEL_LOGO_URIS, + Context.MODE_PRIVATE); + SharedPreferences.Editor sharedPreferencesEditor = sharedPreferences.edit(); + Map<String, ?> uncheckedChannels = sharedPreferences.getAll(); + boolean isFirstTimeFetchChannelLogo = sharedPreferences.getBoolean( + PREF_KEY_IS_FIRST_TIME_FETCH_CHANNEL_LOGO, true); + // Iterating channels. + for (Channel channel : mChannels) { + String channelIdString = Long.toString(channel.getId()); + String storedChannelLogoUri = (String) uncheckedChannels.remove(channelIdString); + if (!TextUtils.isEmpty(channel.getLogoUri()) + && !TextUtils.equals(storedChannelLogoUri, channel.getLogoUri())) { + channelsToUpdate.add(channel); + sharedPreferencesEditor.putString(channelIdString, channel.getLogoUri()); + } else if (TextUtils.isEmpty(channel.getLogoUri()) + && (!TextUtils.isEmpty(storedChannelLogoUri) + || isFirstTimeFetchChannelLogo)) { + channelsToRemove.add(channel); + sharedPreferencesEditor.remove(channelIdString); } - channelNameLogoUriMap.putAll(readTmsFile(mContext, TMS_KR_TABLE_FILE)); - } catch (IOException e) { - Log.e(TAG, "Loading TMS data failed.", e); - return null; } - if (isCancelled()) { - if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); - return null; + + // Removes non existing channels from SharedPreferences. + for (String channelId : uncheckedChannels.keySet()) { + sharedPreferencesEditor.remove(channelId); } - // Iterating channels. - for (Channel channel : mChannels) { + // Updates channel logos. + for (Channel channel : channelsToUpdate) { if (isCancelled()) { if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); return null; } - // Download the channel logo. - if (TextUtils.isEmpty(channel.getDisplayName())) { - if (DEBUG) { - Log.d(TAG, "The channel with ID (" + channel.getId() - + ") doesn't have the display name."); - } - sChannelIdBlackListSet.add(channel.getId()); - continue; - } - String channelName = channel.getDisplayName().trim(); - String logoUri = channelNameLogoUriMap.get(channelName); - if (TextUtils.isEmpty(logoUri)) { - if (DEBUG) { - Log.d(TAG, "Can't find a logo URI for channel '" + channelName + "'"); - } - // Find the candidate names. If the channel name is CNN-HD, then find CNNHD - // and CNN. Or if the channel name is KQED+, then find KQED. - String[] splitNames = channelName.split(NAME_SEPARATOR_FOR_DB); - if (splitNames.length > 1) { - StringBuilder sb = new StringBuilder(); - for (String splitName : splitNames) { - sb.append(splitName); - } - logoUri = channelNameLogoUriMap.get(sb.toString()); - if (DEBUG) { - if (TextUtils.isEmpty(logoUri)) { - Log.d(TAG, "Can't find a logo URI for channel '" + sb.toString() - + "'"); - } - } - } - if (TextUtils.isEmpty(logoUri) - && splitNames[0].length() != channelName.length()) { - logoUri = channelNameLogoUriMap.get(splitNames[0]); - if (DEBUG) { - if (TextUtils.isEmpty(logoUri)) { - Log.d(TAG, "Can't find a logo URI for channel '" + splitNames[0] - + "'"); - } - } - } - } - if (TextUtils.isEmpty(logoUri)) { - sChannelIdBlackListSet.add(channel.getId()); - continue; - } + // Downloads the channel logo. + String logoUri = channel.getLogoUri(); ScaledBitmapInfo bitmapInfo = BitmapUtils.decodeSampledBitmapFromUriString( mContext, logoUri, Integer.MAX_VALUE, Integer.MAX_VALUE); if (bitmapInfo == null) { Log.e(TAG, "Failed to load bitmap. {channelName=" + channel.getDisplayName() + ", " + "logoUri=" + logoUri + "}"); - sChannelIdBlackListSet.add(channel.getId()); continue; } if (isCancelled()) { @@ -264,12 +148,15 @@ public class ChannelLogoFetcher { return null; } - // Insert the logo to DB. + // Inserts the logo to DB. Uri dstLogoUri = TvContract.buildChannelLogoUri(channel.getId()); try (OutputStream os = mContext.getContentResolver().openOutputStream(dstLogoUri)) { bitmapInfo.bitmap.compress(CompressFormat.PNG, 100, os); } catch (IOException e) { Log.e(TAG, "Failed to write " + logoUri + " to " + dstLogoUri, e); + // Removes it from the shared preference for the failed channels to make it + // retry next time. + sharedPreferencesEditor.remove(Long.toString(channel.getId())); continue; } if (DEBUG) { @@ -277,63 +164,35 @@ public class ChannelLogoFetcher { + dstLogoUri + "}"); } } - if (DEBUG) Log.d(TAG, "Fetching logos has been finished successfully."); - return null; - } - @WorkerThread - private Map<String, String> readTmsFile(Context context, String fileName) - throws IOException { - try (BufferedReader reader = new BufferedReader(new InputStreamReader( - context.getAssets().open(fileName)))) { - Map<String, String> channelNameLogoUriMap = new HashMap<>(); - String line; - while ((line = reader.readLine()) != null && !isCancelled()) { - String[] data = line.split(FIELD_SEPARATOR); - if (data.length != INDEX_LOGO_URI + 1) { - if (DEBUG) Log.d(TAG, "Invalid or comment row: " + line); - continue; - } - addChannelNames(channelNameLogoUriMap, - data[INDEX_NAME].toUpperCase(Locale.getDefault()), - data[INDEX_LOGO_URI]); - addChannelNames(channelNameLogoUriMap, - data[INDEX_SHORT_NAME].toUpperCase(Locale.getDefault()), - data[INDEX_LOGO_URI]); - addChannelNames(channelNameLogoUriMap, - data[INDEX_CALL_SIGN].toUpperCase(Locale.getDefault()), - data[INDEX_LOGO_URI]); + // Removes the logos for the channels that have logos before but now + // their logo uris are null. + boolean deleteChannelLogoFailed = false; + if (!channelsToRemove.isEmpty()) { + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (Channel channel : channelsToRemove) { + ops.add(ContentProviderOperation.newDelete( + TvContract.buildChannelLogoUri(channel.getId())).build()); + } + try { + mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); + } catch (RemoteException | OperationApplicationException e) { + deleteChannelLogoFailed = true; + Log.e(TAG, "Error deleting obsolete channels", e); } - return channelNameLogoUriMap; } - } - - private void addChannelNames(Map<String, String> channelNameLogoUriMap, String channelName, - String logoUri) { - if (!TextUtils.isEmpty(channelName)) { - channelNameLogoUriMap.put(channelName, logoUri); - // Find the candidate names. - // If the name is like "W05AAD (W05AA-D)", then split the names into "W05AAD" and - // "W05AA-D" - String[] splitNames = channelName.split(NAME_SEPARATOR_FOR_TMS); - if (splitNames.length > 1) { - for (String name : splitNames) { - name = name.trim(); - if (channelNameLogoUriMap.get(name) == null) { - channelNameLogoUriMap.put(name, logoUri); - } - } - } + if (isFirstTimeFetchChannelLogo && !deleteChannelLogoFailed) { + sharedPreferencesEditor.putBoolean( + PREF_KEY_IS_FIRST_TIME_FETCH_CHANNEL_LOGO, false); } + sharedPreferencesEditor.commit(); + if (DEBUG) Log.d(TAG, "Fetching logos has been finished successfully."); + return null; } @Override protected void onPostExecute(Void result) { - synchronized (sLock) { - if (sFetchTask == this) { - sFetchTask = null; - } - } + sFetchTask = null; } } } diff --git a/src/com/android/tv/data/ChannelNumber.java b/src/com/android/tv/data/ChannelNumber.java index 59021609..29054aa5 100644 --- a/src/com/android/tv/data/ChannelNumber.java +++ b/src/com/android/tv/data/ChannelNumber.java @@ -17,37 +17,38 @@ package com.android.tv.data; import android.support.annotation.NonNull; +import android.text.TextUtils; import android.view.KeyEvent; +import com.android.tv.util.StringUtils; + +import java.util.Objects; + /** * A convenience class to handle channel number. */ public final class ChannelNumber implements Comparable<ChannelNumber> { - public static final String PRIMARY_CHANNEL_DELIMITER = "-"; - public static final String[] CHANNEL_DELIMITERS = {"-", ".", " "}; - private static final int[] CHANNEL_DELIMITER_KEYCODES = { KeyEvent.KEYCODE_MINUS, KeyEvent.KEYCODE_NUMPAD_SUBTRACT, KeyEvent.KEYCODE_PERIOD, KeyEvent.KEYCODE_NUMPAD_DOT, KeyEvent.KEYCODE_SPACE }; + /** The major part of the channel number. */ public String majorNumber; + /** The flag which indicates whether it has a delimiter or not. */ public boolean hasDelimiter; + /** The major part of the channel number. */ public String minorNumber; public ChannelNumber() { reset(); } - public ChannelNumber(String major, boolean hasDelimiter, String minor) { - setChannelNumber(major, hasDelimiter, minor); - } - public void reset() { setChannelNumber("", false, ""); } - public void setChannelNumber(String majorNumber, boolean hasDelimiter, String minorNumber) { + private void setChannelNumber(String majorNumber, boolean hasDelimiter, String minorNumber) { this.majorNumber = majorNumber; this.hasDelimiter = hasDelimiter; this.minorNumber = minorNumber; @@ -56,7 +57,7 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { @Override public String toString() { if (hasDelimiter) { - return majorNumber + PRIMARY_CHANNEL_DELIMITER + minorNumber; + return majorNumber + Channel.CHANNEL_NUMBER_DELIMITER + minorNumber; } return majorNumber; } @@ -75,6 +76,22 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { return major - opponentMajor; } + @Override + public boolean equals(Object obj) { + if (obj instanceof ChannelNumber) { + ChannelNumber channelNumber = (ChannelNumber) obj; + return TextUtils.equals(majorNumber, channelNumber.majorNumber) + && TextUtils.equals(minorNumber, channelNumber.minorNumber) + && hasDelimiter == channelNumber.hasDelimiter; + } + return super.equals(obj); + } + + @Override + public int hashCode() { + return Objects.hash(majorNumber, hasDelimiter, minorNumber); + } + public static boolean isChannelNumberDelimiterKey(int keyCode) { for (int delimiterKeyCode : CHANNEL_DELIMITER_KEYCODES) { if (delimiterKeyCode == keyCode) { @@ -84,22 +101,22 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { return false; } + /** + * Returns the ChannelNumber instance. + * <p> + * Note that all the channel number argument should be normalized by + * {@link Channel#normalizeDisplayNumber}. The channels retrieved from + * {@link ChannelDataManager} are already normalized. + */ public static ChannelNumber parseChannelNumber(String number) { if (number == null) { return null; } ChannelNumber ret = new ChannelNumber(); - int indexOfDelimiter = -1; - for (String delimiter : CHANNEL_DELIMITERS) { - indexOfDelimiter = number.indexOf(delimiter); - if (indexOfDelimiter >= 0) { - break; - } - } + int indexOfDelimiter = number.indexOf(Channel.CHANNEL_NUMBER_DELIMITER); if (indexOfDelimiter == 0 || indexOfDelimiter == number.length() - 1) { return null; - } - if (indexOfDelimiter < 0) { + } else if (indexOfDelimiter < 0) { ret.majorNumber = number; if (!isInteger(ret.majorNumber)) { return null; @@ -115,25 +132,31 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { return ret; } + /** + * Compares the channel numbers. + * <p> + * Note that all the channel number arguments should be normalized by + * {@link Channel#normalizeDisplayNumber}. The channels retrieved from + * {@link ChannelDataManager} are already normalized. + */ public static int compare(String lhs, String rhs) { ChannelNumber lhsNumber = parseChannelNumber(lhs); ChannelNumber rhsNumber = parseChannelNumber(rhs); + // Null first if (lhsNumber == null && rhsNumber == null) { - return 0; + return StringUtils.compare(lhs, rhs); } else if (lhsNumber == null /* && rhsNumber != null */) { return -1; - } else if (lhsNumber != null && rhsNumber == null) { + } else if (rhsNumber == null) { return 1; } return lhsNumber.compareTo(rhsNumber); } - public static boolean isInteger(String string) { + private static boolean isInteger(String string) { try { Integer.parseInt(string); - } catch(NumberFormatException e) { - return false; - } catch(NullPointerException e) { + } catch(NumberFormatException | NullPointerException e) { return false; } return true; diff --git a/src/com/android/tv/data/InternalDataUtils.java b/src/com/android/tv/data/InternalDataUtils.java index 6054f089..e33ca18f 100644 --- a/src/com/android/tv/data/InternalDataUtils.java +++ b/src/com/android/tv/data/InternalDataUtils.java @@ -21,7 +21,7 @@ import android.text.TextUtils; import android.util.Log; import com.android.tv.data.Program.CriticScore; -import com.android.tv.dvr.RecordedProgram; +import com.android.tv.dvr.data.RecordedProgram; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; diff --git a/src/com/android/tv/data/PreviewDataManager.java b/src/com/android/tv/data/PreviewDataManager.java new file mode 100644 index 00000000..01a58520 --- /dev/null +++ b/src/com/android/tv/data/PreviewDataManager.java @@ -0,0 +1,636 @@ +/* + * Copyright (C) 2017 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.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.support.annotation.IntDef; +import android.support.annotation.MainThread; +import android.support.media.tv.ChannelLogoUtils; +import android.support.media.tv.PreviewProgram; +import android.util.Log; +import android.util.Pair; + +import com.android.tv.R; +import com.android.tv.util.PermissionUtils; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * Class to manage the preview data. + */ +@TargetApi(Build.VERSION_CODES.O) +@MainThread +public class PreviewDataManager { + private static final String TAG = "PreviewDataManager"; + // STOPSHIP: set it to false. + private static final boolean DEBUG = true; + + /** + * Invalid preview channel ID. + */ + public static final long INVALID_PREVIEW_CHANNEL_ID = -1; + @IntDef({TYPE_DEFAULT_PREVIEW_CHANNEL, TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL}) + @Retention(RetentionPolicy.SOURCE) + public @interface PreviewChannelType{} + + /** + * Type of default preview channel + */ + public static final long TYPE_DEFAULT_PREVIEW_CHANNEL = 1; + /** + * Type of recorded program channel + */ + public static final long TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL = 2; + + private final Context mContext; + private final ContentResolver mContentResolver; + private boolean mLoadFinished; + private PreviewData mPreviewData = new PreviewData(); + private final Set<PreviewDataListener> mPreviewDataListeners = new CopyOnWriteArraySet<>(); + + private QueryPreviewDataTask mQueryPreviewTask; + private final Map<Long, CreatePreviewChannelTask> mCreatePreviewChannelTasks = + new HashMap<>(); + private final Map<Long, UpdatePreviewProgramTask> mUpdatePreviewProgramTasks = new HashMap<>(); + + private final int mPreviewChannelLogoWidth; + private final int mPreviewChannelLogoHeight; + + public PreviewDataManager(Context context) { + mContext = context.getApplicationContext(); + mContentResolver = context.getContentResolver(); + mPreviewChannelLogoWidth = mContext.getResources().getDimensionPixelSize( + R.dimen.preview_channel_logo_width); + mPreviewChannelLogoHeight = mContext.getResources().getDimensionPixelSize( + R.dimen.preview_channel_logo_height); + } + + /** + * Starts the preview data manager. + */ + public void start() { + if (mQueryPreviewTask == null) { + mQueryPreviewTask = new QueryPreviewDataTask(); + mQueryPreviewTask.execute(); + } + } + + /** + * Stops the preview data manager. + */ + public void stop() { + if (mQueryPreviewTask != null) { + mQueryPreviewTask.cancel(true); + } + for (CreatePreviewChannelTask createPreviewChannelTask + : mCreatePreviewChannelTasks.values()) { + createPreviewChannelTask.cancel(true); + } + for (UpdatePreviewProgramTask updatePreviewProgramTask + : mUpdatePreviewProgramTasks.values()) { + updatePreviewProgramTask.cancel(true); + } + + mQueryPreviewTask = null; + mCreatePreviewChannelTasks.clear(); + mUpdatePreviewProgramTasks.clear(); + } + + /** + * Gets preview channel ID from the preview channel type. + */ + public @PreviewChannelType long getPreviewChannelId(long previewChannelType) { + return mPreviewData.getPreviewChannelId(previewChannelType); + } + + /** + * Creates default preview channel. + */ + public void createDefaultPreviewChannel( + OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { + createPreviewChannel(TYPE_DEFAULT_PREVIEW_CHANNEL, onPreviewChannelCreationResultListener); + } + + /** + * Creates a preview channel for specific channel type. + */ + public void createPreviewChannel(@PreviewChannelType long previewChannelType, + OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { + CreatePreviewChannelTask currentRunningCreateTask = + mCreatePreviewChannelTasks.get(previewChannelType); + if (currentRunningCreateTask == null) { + CreatePreviewChannelTask createPreviewChannelTask = new CreatePreviewChannelTask( + previewChannelType); + createPreviewChannelTask.addOnPreviewChannelCreationResultListener( + onPreviewChannelCreationResultListener); + createPreviewChannelTask.execute(); + mCreatePreviewChannelTasks.put(previewChannelType, createPreviewChannelTask); + } else { + currentRunningCreateTask.addOnPreviewChannelCreationResultListener( + onPreviewChannelCreationResultListener); + } + } + + /** + * Returns {@code true} if the preview data is loaded. + */ + public boolean isLoadFinished() { + return mLoadFinished; + } + + /** + * Adds listener. + */ + public void addListener(PreviewDataListener previewDataListener) { + mPreviewDataListeners.add(previewDataListener); + } + + /** + * Removes listener. + */ + public void removeListener(PreviewDataListener previewDataListener) { + mPreviewDataListeners.remove(previewDataListener); + } + + /** + * Updates the preview programs table for a specific preview channel. + */ + public void updatePreviewProgramsForChannel(long previewChannelId, + Set<PreviewProgramContent> programs, PreviewDataListener previewDataListener) { + UpdatePreviewProgramTask currentRunningUpdateTask = + mUpdatePreviewProgramTasks.get(previewChannelId); + if (currentRunningUpdateTask != null + && currentRunningUpdateTask.getPrograms().equals(programs)) { + currentRunningUpdateTask.addPreviewDataListener(previewDataListener); + return; + } + UpdatePreviewProgramTask updatePreviewProgramTask = + new UpdatePreviewProgramTask(previewChannelId, programs); + updatePreviewProgramTask.addPreviewDataListener(previewDataListener); + if (currentRunningUpdateTask != null) { + currentRunningUpdateTask.cancel(true); + currentRunningUpdateTask.saveStatus(); + updatePreviewProgramTask.addPreviewDataListeners( + currentRunningUpdateTask.getPreviewDataListeners()); + } + updatePreviewProgramTask.execute(); + mUpdatePreviewProgramTasks.put(previewChannelId, updatePreviewProgramTask); + } + + private void notifyPreviewDataLoadFinished() { + for (PreviewDataListener l : mPreviewDataListeners) { + l.onPreviewDataLoadFinished(); + } + } + + public interface PreviewDataListener { + /** + * Called when the preview data is loaded. + */ + void onPreviewDataLoadFinished(); + + /** + * Called when the preview data is updated. + */ + void onPreviewDataUpdateFinished(); + } + + public interface OnPreviewChannelCreationResultListener { + /** + * Called when the creation of preview channel is finished. + * @param createdPreviewChannelId The preview channel ID if created successfully, + * otherwise it's {@value #INVALID_PREVIEW_CHANNEL_ID}. + */ + void onPreviewChannelCreationResult(long createdPreviewChannelId); + } + + private final class QueryPreviewDataTask extends AsyncTask<Void, Void, PreviewData> { + private final String PARAM_PREVIEW = "preview"; + private final String mChannelSelection = TvContract.Channels.COLUMN_PACKAGE_NAME + "=?"; + + @Override + protected PreviewData doInBackground(Void... voids) { + // Query preview channels and programs. + if (DEBUG) Log.d(TAG, "QueryPreviewDataTask.doInBackground"); + PreviewData previewData = new PreviewData(); + try { + Uri previewChannelsUri = + PreviewDataUtils.addQueryParamToUri( + TvContract.Channels.CONTENT_URI, + new Pair<>(PARAM_PREVIEW, String.valueOf(true))); + String packageName = mContext.getPackageName(); + if (PermissionUtils.hasAccessAllEpg(mContext)) { + try (Cursor cursor = + mContentResolver.query( + previewChannelsUri, + android.support.media.tv.Channel.PROJECTION, + mChannelSelection, + new String[] {packageName}, + null)) { + if (cursor != null) { + while (cursor.moveToNext()) { + android.support.media.tv.Channel previewChannel = + android.support.media.tv.Channel.fromCursor(cursor); + Long previewChannelType = previewChannel.getInternalProviderFlag1(); + if (previewChannelType != null) { + previewData.addPreviewChannelId( + previewChannelType, previewChannel.getId()); + } + } + } + } + } else { + try (Cursor cursor = + mContentResolver.query( + previewChannelsUri, + android.support.media.tv.Channel.PROJECTION, + null, + null, + null)) { + if (cursor != null) { + while (cursor.moveToNext()) { + android.support.media.tv.Channel previewChannel = + android.support.media.tv.Channel.fromCursor(cursor); + Long previewChannelType = previewChannel.getInternalProviderFlag1(); + if (previewChannel.getPackageName() == packageName + && previewChannelType != null) { + previewData.addPreviewChannelId( + previewChannelType, previewChannel.getId()); + } + } + } + } + } + + for (long previewChannelId : previewData.getAllPreviewChannelIds().values()) { + Uri previewProgramsUriForPreviewChannel = + TvContract.buildPreviewProgramsUriForChannel(previewChannelId); + try (Cursor previewProgramCursor = + mContentResolver.query( + previewProgramsUriForPreviewChannel, + PreviewProgram.PROJECTION, + null, + null, + null)) { + if (previewProgramCursor != null) { + while (previewProgramCursor.moveToNext()) { + PreviewProgram previewProgram = + PreviewProgram.fromCursor(previewProgramCursor); + previewData.addPreviewProgram(previewProgram); + } + } + } + } + } catch (SQLException e) { + Log.w(TAG, "Unable to get preview data", e); + } + return previewData; + } + + @Override + protected void onPostExecute(PreviewData result) { + super.onPostExecute(result); + if (mQueryPreviewTask == this) { + mQueryPreviewTask = null; + mPreviewData = new PreviewData(result); + mLoadFinished = true; + notifyPreviewDataLoadFinished(); + } + } + } + + private final class CreatePreviewChannelTask extends AsyncTask<Void, Void, Long> { + private final long mPreviewChannelType; + private Set<OnPreviewChannelCreationResultListener> + mOnPreviewChannelCreationResultListeners = new CopyOnWriteArraySet<>(); + + public CreatePreviewChannelTask(long previewChannelType) { + mPreviewChannelType = previewChannelType; + } + + public void addOnPreviewChannelCreationResultListener( + OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { + if (onPreviewChannelCreationResultListener != null) { + mOnPreviewChannelCreationResultListeners.add( + onPreviewChannelCreationResultListener); + } + } + + @Override + protected Long doInBackground(Void... params) { + if (DEBUG) Log.d(TAG, "CreatePreviewChannelTask.doInBackground"); + long previewChannelId; + try { + Uri channelUri = mContentResolver.insert(TvContract.Channels.CONTENT_URI, + PreviewDataUtils.createPreviewChannel(mContext, mPreviewChannelType) + .toContentValues()); + if (channelUri != null) { + previewChannelId = ContentUris.parseId(channelUri); + } else { + Log.e(TAG, "Fail to insert preview channel"); + return INVALID_PREVIEW_CHANNEL_ID; + } + } catch (UnsupportedOperationException | NumberFormatException e) { + Log.e(TAG, "Fail to get channel ID"); + return INVALID_PREVIEW_CHANNEL_ID; + } + Drawable appIcon = mContext.getApplicationInfo().loadIcon(mContext.getPackageManager()); + if (appIcon != null && appIcon instanceof BitmapDrawable) { + ChannelLogoUtils.storeChannelLogo(mContext, previewChannelId, + Bitmap.createScaledBitmap(((BitmapDrawable) appIcon).getBitmap(), + mPreviewChannelLogoWidth, mPreviewChannelLogoHeight, false)); + } + return previewChannelId; + } + + @Override + protected void onPostExecute(Long result) { + super.onPostExecute(result); + if (result != INVALID_PREVIEW_CHANNEL_ID) { + mPreviewData.addPreviewChannelId(mPreviewChannelType, result); + } + for (OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener + : mOnPreviewChannelCreationResultListeners) { + onPreviewChannelCreationResultListener.onPreviewChannelCreationResult(result); + } + mCreatePreviewChannelTasks.remove(mPreviewChannelType); + } + } + + /** + * Updates the whole data which belongs to the package in preview programs table for a + * specific preview channel with a set of {@link PreviewProgramContent}. + */ + private final class UpdatePreviewProgramTask extends AsyncTask<Void, Void, Void> { + private long mPreviewChannelId; + private Set<PreviewProgramContent> mPrograms; + private Map<Long, Long> mCurrentProgramId2PreviewProgramId; + private Set<PreviewDataListener> mPreviewDataListeners = new CopyOnWriteArraySet<>(); + + public UpdatePreviewProgramTask(long previewChannelId, + Set<PreviewProgramContent> programs) { + mPreviewChannelId = previewChannelId; + mPrograms = programs; + if (mPreviewData.getPreviewProgramIds(previewChannelId) == null) { + mCurrentProgramId2PreviewProgramId = new HashMap<>(); + } else { + mCurrentProgramId2PreviewProgramId = new HashMap<>( + mPreviewData.getPreviewProgramIds(previewChannelId)); + } + } + + public void addPreviewDataListener(PreviewDataListener previewDataListener) { + if (previewDataListener != null) { + mPreviewDataListeners.add(previewDataListener); + } + } + + public void addPreviewDataListeners(Set<PreviewDataListener> previewDataListeners) { + if (previewDataListeners != null) { + mPreviewDataListeners.addAll(previewDataListeners); + } + } + + public Set<PreviewProgramContent> getPrograms() { + return mPrograms; + } + + public Set<PreviewDataListener> getPreviewDataListeners() { + return mPreviewDataListeners; + } + + @Override + protected Void doInBackground(Void... params) { + if (DEBUG) Log.d(TAG, "UpdatePreviewProgamTask.doInBackground"); + Map<Long, Long> uncheckedPrograms = new HashMap<>(mCurrentProgramId2PreviewProgramId); + for (PreviewProgramContent program : mPrograms) { + if (isCancelled()) { + return null; + } + Long existingPreviewProgramId = uncheckedPrograms.remove(program.getId()); + if (existingPreviewProgramId != null) { + if (DEBUG) Log.d(TAG, "Preview program " + existingPreviewProgramId + " " + + "already exists for program " + program.getId()); + continue; + } + try { + Uri programUri = mContentResolver.insert(TvContract.PreviewPrograms.CONTENT_URI, + PreviewDataUtils.createPreviewProgramFromContent(program) + .toContentValues()); + if (programUri != null) { + long previewProgramId = ContentUris.parseId(programUri); + mCurrentProgramId2PreviewProgramId.put(program.getId(), previewProgramId); + if (DEBUG) Log.d(TAG, "Add new preview program " + previewProgramId); + } else { + Log.e(TAG, "Fail to insert preview program"); + } + } catch (Exception e) { + Log.e(TAG, "Fail to get preview program ID"); + } + } + + for (Long key : uncheckedPrograms.keySet()) { + if (isCancelled()) { + return null; + } + try { + if (DEBUG) Log.d(TAG, "Remove preview program " + uncheckedPrograms.get(key)); + mContentResolver.delete(TvContract.buildPreviewProgramUri( + uncheckedPrograms.get(key)), null, null); + mCurrentProgramId2PreviewProgramId.remove(key); + } catch (Exception e) { + Log.e(TAG, "Fail to remove preview program " + uncheckedPrograms.get(key)); + } + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + mPreviewData.setPreviewProgramIds( + mPreviewChannelId, mCurrentProgramId2PreviewProgramId); + mUpdatePreviewProgramTasks.remove(mPreviewChannelId); + for (PreviewDataListener previewDataListener : mPreviewDataListeners) { + previewDataListener.onPreviewDataUpdateFinished(); + } + } + + public void saveStatus() { + mPreviewData.setPreviewProgramIds( + mPreviewChannelId, mCurrentProgramId2PreviewProgramId); + } + } + + /** + * Class to store the query result of preview data. + */ + private static final class PreviewData { + private Map<Long, Long> mPreviewChannelType2Id = new HashMap<>(); + private Map<Long, Map<Long, Long>> mProgramId2PreviewProgramId = new HashMap<>(); + + PreviewData() { + mPreviewChannelType2Id = new HashMap<>(); + mProgramId2PreviewProgramId = new HashMap<>(); + } + + PreviewData(PreviewData previewData) { + mPreviewChannelType2Id = new HashMap<>(previewData.mPreviewChannelType2Id); + mProgramId2PreviewProgramId = new HashMap<>(previewData.mProgramId2PreviewProgramId); + } + + public void addPreviewProgram(PreviewProgram previewProgram) { + long previewChannelId = previewProgram.getChannelId(); + Map<Long, Long> programId2PreviewProgram = + mProgramId2PreviewProgramId.get(previewChannelId); + if (programId2PreviewProgram == null) { + programId2PreviewProgram = new HashMap<>(); + } + mProgramId2PreviewProgramId.put(previewChannelId, programId2PreviewProgram); + if (previewProgram.getInternalProviderId() != null) { + programId2PreviewProgram.put( + Long.parseLong(previewProgram.getInternalProviderId()), + previewProgram.getId()); + } + } + + public @PreviewChannelType long getPreviewChannelId(long previewChannelType) { + Long result = mPreviewChannelType2Id.get(previewChannelType); + return result == null ? INVALID_PREVIEW_CHANNEL_ID : result; + } + + public Map<Long, Long> getAllPreviewChannelIds() { + return mPreviewChannelType2Id; + } + + public void addPreviewChannelId(long previewChannelType, long previewChannelId) { + mPreviewChannelType2Id.put(previewChannelType, previewChannelId); + } + + public void removePreviewChannelId(long previewChannelType) { + mPreviewChannelType2Id.remove(previewChannelType); + } + + public void removePreviewChannel(long previewChannelId) { + removePreviewChannelId(previewChannelId); + removePreviewProgramIds(previewChannelId); + } + + public Map<Long, Long> getPreviewProgramIds(long previewChannelId) { + return mProgramId2PreviewProgramId.get(previewChannelId); + } + + public Map<Long, Map<Long, Long>> getAllPreviewProgramIds() { + return mProgramId2PreviewProgramId; + } + + public void setPreviewProgramIds( + long previewChannelId, Map<Long, Long> programId2PreviewProgramId) { + mProgramId2PreviewProgramId.put(previewChannelId, programId2PreviewProgramId); + } + + public void removePreviewProgramIds(long previewChannelId) { + mProgramId2PreviewProgramId.remove(previewChannelId); + } + } + + /** + * A utils class for preview data. + */ + public final static class PreviewDataUtils { + /** + * Creates a preview channel. + */ + public static android.support.media.tv.Channel createPreviewChannel( + Context context, @PreviewChannelType long previewChannelType) { + if (previewChannelType == TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL) { + return createRecordedProgramPreviewChannel(context, previewChannelType); + } + return createDefaultPreviewChannel(context, previewChannelType); + } + + private static android.support.media.tv.Channel createDefaultPreviewChannel( + Context context, @PreviewChannelType long previewChannelType) { + android.support.media.tv.Channel.Builder builder = + new android.support.media.tv.Channel.Builder(); + CharSequence appLabel = + context.getApplicationInfo().loadLabel(context.getPackageManager()); + CharSequence appDescription = + context.getApplicationInfo().loadDescription(context.getPackageManager()); + builder.setType(TvContract.Channels.TYPE_PREVIEW) + .setDisplayName(appLabel == null ? null : appLabel.toString()) + .setDescription(appDescription == null ? null : appDescription.toString()) + .setAppLinkIntentUri(TvContract.Channels.CONTENT_URI) + .setInternalProviderFlag1(previewChannelType); + return builder.build(); + } + + private static android.support.media.tv.Channel createRecordedProgramPreviewChannel( + Context context, @PreviewChannelType long previewChannelType) { + android.support.media.tv.Channel.Builder builder = + new android.support.media.tv.Channel.Builder(); + builder.setType(TvContract.Channels.TYPE_PREVIEW) + .setDisplayName(context.getResources().getString( + R.string.recorded_programs_preview_channel)) + .setAppLinkIntentUri(TvContract.Channels.CONTENT_URI) + .setInternalProviderFlag1(previewChannelType); + return builder.build(); + } + + /** + * Creates a preview program. + */ + public static PreviewProgram createPreviewProgramFromContent( + PreviewProgramContent program) { + PreviewProgram.Builder builder = new PreviewProgram.Builder(); + builder.setChannelId(program.getPreviewChannelId()) + .setType(program.getType()) + .setLive(program.getLive()) + .setTitle(program.getTitle()) + .setDescription(program.getDescription()) + .setPosterArtUri(program.getPosterArtUri()) + .setIntentUri(program.getIntentUri()) + .setPreviewVideoUri(program.getPreviewVideoUri()) + .setInternalProviderId(Long.toString(program.getId())); + return builder.build(); + } + + /** + * Appends query parameters to a Uri. + */ + public static Uri addQueryParamToUri(Uri uri, Pair<String, String> param) { + return uri.buildUpon().appendQueryParameter(param.first, param.second).build(); + } + } +} diff --git a/src/com/android/tv/data/PreviewProgramContent.java b/src/com/android/tv/data/PreviewProgramContent.java new file mode 100644 index 00000000..39f5051d --- /dev/null +++ b/src/com/android/tv/data/PreviewProgramContent.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2017 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.media.tv.TvContract; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Pair; + +import com.android.tv.TvApplication; +import com.android.tv.dvr.data.RecordedProgram; + +import java.util.Objects; + +/** + * A class to store the content of preview programs. + */ +public class PreviewProgramContent { + private final static String PARAM_INPUT = "input"; + + private long mId; + private long mPreviewChannelId; + private int mType; + private boolean mLive; + private String mTitle; + private String mDescription; + private Uri mPosterArtUri; + private Uri mIntentUri; + private Uri mPreviewVideoUri; + + /** + * Create preview program content from {@link Program} + */ + public static PreviewProgramContent createFromProgram(Context context, + long previewChannelId, Program program) { + Channel channel = TvApplication.getSingletons(context).getChannelDataManager() + .getChannel(program.getChannelId()); + if (channel == null) { + return null; + } + String channelDisplayName = channel.getDisplayName(); + return new PreviewProgramContent.Builder() + .setId(program.getId()) + .setPreviewChannelId(previewChannelId) + .setType(TvContract.PreviewPrograms.TYPE_CHANNEL) + .setLive(true) + .setTitle(program.getTitle()) + .setDescription(!TextUtils.isEmpty(channelDisplayName) + ? channelDisplayName : channel.getDisplayNumber()) + .setPosterArtUri(Uri.parse(program.getPosterArtUri())) + .setIntentUri(channel.getUri()) + .setPreviewVideoUri(PreviewDataManager.PreviewDataUtils.addQueryParamToUri( + channel.getUri(), new Pair<>(PARAM_INPUT, channel.getInputId()))) + .build(); + } + + /** + * Create preview program content from {@link RecordedProgram} + */ + public static PreviewProgramContent createFromRecordedProgram( + Context context, long previewChannelId, RecordedProgram recordedProgram) { + Channel channel = TvApplication.getSingletons(context).getChannelDataManager() + .getChannel(recordedProgram.getChannelId()); + String channelDisplayName = null; + if (channel != null) { + channelDisplayName = channel.getDisplayName(); + } + Uri recordedProgramUri = TvContract.buildRecordedProgramUri(recordedProgram.getId()); + return new PreviewProgramContent.Builder() + .setId(recordedProgram.getId()) + .setPreviewChannelId(previewChannelId) + .setType(TvContract.PreviewPrograms.TYPE_CLIP) + .setTitle(recordedProgram.getTitle()) + .setDescription(channelDisplayName != null ? channelDisplayName : "") + .setPosterArtUri(Uri.parse(recordedProgram.getPosterArtUri())) + .setIntentUri(recordedProgramUri) + .setPreviewVideoUri(PreviewDataManager.PreviewDataUtils.addQueryParamToUri( + recordedProgramUri, new Pair<>(PARAM_INPUT, recordedProgram.getInputId()))) + .build(); + } + + private PreviewProgramContent() { } + + public void copyFrom(PreviewProgramContent other) { + if (this == other) { + return; + } + mId = other.mId; + mPreviewChannelId = other.mPreviewChannelId; + mType = other.mType; + mLive = other.mLive; + mTitle = other.mTitle; + mDescription = other.mDescription; + mPosterArtUri = other.mPosterArtUri; + mIntentUri = other.mIntentUri; + mPreviewVideoUri = other.mPreviewVideoUri; + } + + /** + * Returns the id, which is an identification. It usually comes from the original data which + * create the {@PreviewProgramContent}. + */ + public long getId() { + return mId; + } + + /** + * Returns the preview channel id which the preview program belongs to. + */ + public long getPreviewChannelId() { + return mPreviewChannelId; + } + + /** + * Returns the type of the preview program. + */ + public int getType() { + return mType; + } + + /** + * Returns whether the preview program is live or not. + */ + public boolean getLive() { + return mLive; + } + + /** + * Returns the title of the preview program. + */ + public String getTitle() { + return mTitle; + } + + /** + * Returns the description of the preview program. + */ + public String getDescription() { + return mDescription; + } + + /** + * Returns the poster art uri of the preview program. + */ + public Uri getPosterArtUri() { + return mPosterArtUri; + } + + /** + * Returns the intent uri of the preview program. + */ + public Uri getIntentUri() { + return mIntentUri; + } + + /** + * Returns the preview video uri of the preview program. + */ + public Uri getPreviewVideoUri() { + return mPreviewVideoUri; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof PreviewProgramContent)) { + return false; + } + PreviewProgramContent previewProgramContent = (PreviewProgramContent) other; + return previewProgramContent.mId == mId + && previewProgramContent.mPreviewChannelId == mPreviewChannelId + && previewProgramContent.mType == mType + && previewProgramContent.mLive == mLive + && Objects.equals(previewProgramContent.mTitle, mTitle) + && Objects.equals(previewProgramContent.mDescription, mDescription) + && Objects.equals(previewProgramContent.mPosterArtUri, mPosterArtUri) + && Objects.equals(previewProgramContent.mIntentUri, mIntentUri) + && Objects.equals(previewProgramContent.mPreviewVideoUri, mPreviewVideoUri); + } + + @Override + public int hashCode() { + return Objects.hash(mId, mPreviewChannelId, mType, mLive, mTitle, mDescription, + mPosterArtUri, mIntentUri, mPreviewVideoUri); + } + + public static final class Builder { + private final PreviewProgramContent mPreviewProgramContent; + + public Builder() { + mPreviewProgramContent = new PreviewProgramContent(); + } + + public Builder setId(long id) { + mPreviewProgramContent.mId = id; + return this; + } + + public Builder setPreviewChannelId(long previewChannelId) { + mPreviewProgramContent.mPreviewChannelId = previewChannelId; + return this; + } + + public Builder setType(int type) { + mPreviewProgramContent.mType = type; + return this; + } + + public Builder setLive(boolean live) { + mPreviewProgramContent.mLive = live; + return this; + } + + public Builder setTitle(String title) { + mPreviewProgramContent.mTitle = title; + return this; + } + + public Builder setDescription(String description) { + mPreviewProgramContent.mDescription = description; + return this; + } + + public Builder setPosterArtUri(Uri posterArtUri) { + mPreviewProgramContent.mPosterArtUri = posterArtUri; + return this; + } + + public Builder setIntentUri(Uri intentUri) { + mPreviewProgramContent.mIntentUri = intentUri; + return this; + } + + public Builder setPreviewVideoUri(Uri previewVideoUri) { + mPreviewProgramContent.mPreviewVideoUri = previewVideoUri; + return this; + } + + public PreviewProgramContent build() { + PreviewProgramContent previewProgramContent = new PreviewProgramContent(); + previewProgramContent.copyFrom(mPreviewProgramContent); + return previewProgramContent; + } + } +} diff --git a/src/com/android/tv/data/Program.java b/src/com/android/tv/data/Program.java index b9cd3d8d..071c7024 100644 --- a/src/com/android/tv/data/Program.java +++ b/src/com/android/tv/data/Program.java @@ -16,21 +16,23 @@ package com.android.tv.data; +import android.annotation.SuppressLint; +import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.media.tv.TvContentRating; import android.media.tv.TvContract; +import android.media.tv.TvContract.Programs; +import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.UiThread; import android.support.annotation.VisibleForTesting; -import android.support.v4.os.BuildCompat; import android.text.TextUtils; import android.util.Log; -import com.android.tv.R; import com.android.tv.common.BuildConfig; import com.android.tv.common.CollectionUtils; import com.android.tv.common.TvContentRatingCache; @@ -88,9 +90,11 @@ public final class Program extends BaseProgram implements Comparable<Program>, P public static final String[] PROJECTION = createProjection(); private static String[] createProjection() { - return CollectionUtils - .concatAll(PROJECTION_BASE, BuildCompat.isAtLeastN() ? PROJECTION_ADDED_IN_NYC - : PROJECTION_DEPRECATED_IN_NYC); + return CollectionUtils.concatAll( + PROJECTION_BASE, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + ? PROJECTION_ADDED_IN_NYC + : PROJECTION_DEPRECATED_IN_NYC); } /** @@ -135,7 +139,7 @@ public final class Program extends BaseProgram implements Comparable<Program>, P InternalDataUtils.deserializeInternalProviderData(cursor.getBlob(index), builder); } index++; - if (BuildCompat.isAtLeastN()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { builder.setSeasonNumber(cursor.getString(index++)); builder.setSeasonTitle(cursor.getString(index++)); builder.setEpisodeNumber(cursor.getString(index++)); @@ -213,11 +217,6 @@ public final class Program extends BaseProgram implements Comparable<Program>, P private TvContentRating[] mContentRatings; private boolean mRecordingProhibited; - /** - * TODO(DVR): Need to fill the following data. - */ - private boolean mRecordingScheduled; - private Program() { // Do nothing. } @@ -268,46 +267,12 @@ public final class Program extends BaseProgram implements Comparable<Program>, P /** * Returns the episode title. */ - public String getEpisodeTitle() { - return mEpisodeTitle; - } - - /** - * Returns season number, episode number and episode title for display. - */ @Override - public String getEpisodeDisplayTitle(Context context) { - if (!TextUtils.isEmpty(mEpisodeNumber)) { - String episodeTitle = mEpisodeTitle == null ? "" : mEpisodeTitle; - if (TextUtils.equals(mSeasonNumber, "0")) { - // Do not show "S0: ". - return String.format(context.getResources().getString( - R.string.display_episode_title_format_no_season_number), - mEpisodeNumber, episodeTitle); - } else { - return String.format(context.getResources().getString( - R.string.display_episode_title_format), - mSeasonNumber, mEpisodeNumber, episodeTitle); - } - } + public String getEpisodeTitle() { return mEpisodeTitle; } @Override - public String getTitleWithEpisodeNumber(Context context) { - if (TextUtils.isEmpty(mTitle)) { - return mTitle; - } - if (TextUtils.isEmpty(mSeasonNumber) || mSeasonNumber.equals("0")) { - return TextUtils.isEmpty(mEpisodeNumber) ? mTitle : context.getString( - R.string.program_title_with_episode_number_no_season, mTitle, mEpisodeNumber); - } else { - return context.getString(R.string.program_title_with_episode_number, mTitle, - mSeasonNumber, mEpisodeNumber); - } - } - - @Override public String getSeasonNumber() { return mSeasonNumber; } @@ -361,6 +326,8 @@ public final class Program extends BaseProgram implements Comparable<Program>, P return mCriticScores; } + @Nullable + @Override public TvContentRating[] getContentRatings() { return mContentRatings; } @@ -495,6 +462,63 @@ public final class Program extends BaseProgram implements Comparable<Program>, P return builder.append("}").toString(); } + /** + * Translates a {@link Program} to {@link ContentValues} that are ready to be written into + * Database. + */ + @SuppressLint("InlinedApi") + @SuppressWarnings("deprecation") + public static ContentValues toContentValues(Program program) { + ContentValues values = new ContentValues(); + values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId()); + putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle()); + putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + putValue(values, TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER, + program.getSeasonNumber()); + putValue(values, TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER, + program.getEpisodeNumber()); + } else { + putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber()); + putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber()); + } + putValue(values, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, program.getDescription()); + putValue(values, TvContract.Programs.COLUMN_LONG_DESCRIPTION, program.getLongDescription()); + putValue(values, TvContract.Programs.COLUMN_POSTER_ART_URI, program.getPosterArtUri()); + putValue(values, TvContract.Programs.COLUMN_THUMBNAIL_URI, program.getThumbnailUri()); + String[] canonicalGenres = program.getCanonicalGenres(); + if (canonicalGenres != null && canonicalGenres.length > 0) { + putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, + TvContract.Programs.Genres.encode(canonicalGenres)); + } else { + putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, ""); + } + putValue(values, Programs.COLUMN_CONTENT_RATING, + TvContentRatingCache.contentRatingsToString(program.getContentRatings())); + values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, + program.getStartTimeUtcMillis()); + values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, program.getEndTimeUtcMillis()); + putValue(values, TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA, + InternalDataUtils.serializeInternalProviderData(program)); + return values; + } + + private static void putValue(ContentValues contentValues, String key, String value) { + if (TextUtils.isEmpty(value)) { + contentValues.putNull(key); + } else { + contentValues.put(key, value); + } + } + + private static void putValue(ContentValues contentValues, String key, byte[] value) { + if (value == null || value.length == 0) { + contentValues.putNull(key); + } else { + contentValues.put(key, value); + } + } + public void copyFrom(Program other) { if (this == other) { return; @@ -524,13 +548,6 @@ public final class Program extends BaseProgram implements Comparable<Program>, P } /** - * Checks whether the program is episodic or not. - */ - public boolean isEpisodic() { - return mSeriesId != null; - } - - /** * A Builder for the Program class */ public static final class Builder { @@ -799,8 +816,12 @@ public final class Program extends BaseProgram implements Comparable<Program>, P */ public Program build() { // Generate the series ID for the episodic program of other TV input. - if (TextUtils.isEmpty(mProgram.mSeriesId) + if (TextUtils.isEmpty(mProgram.mTitle)) { + // If title is null, series cannot be generated for this program. + setSeriesId(null); + } else if (TextUtils.isEmpty(mProgram.mSeriesId) && !TextUtils.isEmpty(mProgram.mEpisodeNumber)) { + // If series ID is not set, generate it for the episodic program of other TV input. setSeriesId(BaseProgram.generateSeriesId(mProgram.mPackageName, mProgram.mTitle)); } Program program = new Program(); @@ -820,17 +841,20 @@ public final class Program extends BaseProgram implements Comparable<Program>, P } /** - * Loads the program poster art and returns it via {@code callback}.<p> + * Loads the program poster art and returns it via {@code callback}. * <p> * Note that it may directly call {@code callback} if the program poster art already is loaded. + * + * @return {@code true} if the load is complete and the callback is executed. */ @UiThread - public void loadPosterArt(Context context, int posterArtWidth, int posterArtHeight, + public boolean loadPosterArt(Context context, int posterArtWidth, int posterArtHeight, ImageLoader.ImageLoaderCallback callback) { if (mPosterArtUri == null) { - return; + return false; } - ImageLoader.loadBitmap(context, mPosterArtUri, posterArtWidth, posterArtHeight, callback); + return ImageLoader.loadBitmap( + context, mPosterArtUri, posterArtWidth, posterArtHeight, callback); } public static boolean isDuplicate(Program p1, Program p2) { diff --git a/src/com/android/tv/data/ProgramDataManager.java b/src/com/android/tv/data/ProgramDataManager.java index d2af33a7..8cb5e74a 100644 --- a/src/com/android/tv/data/ProgramDataManager.java +++ b/src/com/android/tv/data/ProgramDataManager.java @@ -26,6 +26,7 @@ import android.net.Uri; import android.os.Handler; import android.os.Looper; import android.os.Message; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; import android.support.annotation.VisibleForTesting; import android.util.ArraySet; @@ -35,8 +36,6 @@ import android.util.LruCache; import com.android.tv.common.MemoryManageable; import com.android.tv.common.SoftPreconditions; -import com.android.tv.data.epg.EpgFetcher; -import com.android.tv.experiments.Experiments; import com.android.tv.util.AsyncDbTask; import com.android.tv.util.Clock; import com.android.tv.util.MultiLongSparseArray; @@ -51,6 +50,7 @@ import java.util.ListIterator; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @MainThread @@ -85,10 +85,12 @@ public class ProgramDataManager implements MemoryManageable { private final Clock mClock; private final ContentResolver mContentResolver; private boolean mStarted; + // Updated only on the main thread. + private volatile boolean mCurrentProgramsLoadFinished; private ProgramsUpdateTask mProgramsUpdateTask; private final LongSparseArray<UpdateCurrentProgramForChannelTask> mProgramUpdateTaskMap = new LongSparseArray<>(); - private final Map<Long, Program> mChannelIdCurrentProgramMap = new HashMap<>(); + private final Map<Long, Program> mChannelIdCurrentProgramMap = new ConcurrentHashMap<>(); private final MultiLongSparseArray<OnCurrentProgramUpdatedListener> mChannelId2ProgramUpdatedListeners = new MultiLongSparseArray<>(); private final Handler mHandler; @@ -109,17 +111,14 @@ public class ProgramDataManager implements MemoryManageable { private boolean mPauseProgramUpdate = false; private final LruCache<Long, Program> mZeroLengthProgramCache = new LruCache<>(10); - private final EpgFetcher mEpgFetcher; + @MainThread public ProgramDataManager(Context context) { - this(context.getContentResolver(), Clock.SYSTEM, Looper.myLooper(), - EpgFetcher.getInstance(context)); + this(context.getContentResolver(), Clock.SYSTEM, Looper.myLooper()); } @VisibleForTesting - ProgramDataManager(ContentResolver contentResolver, Clock time, Looper looper, - EpgFetcher epgFetcher) { - mEpgFetcher = epgFetcher; + ProgramDataManager(ContentResolver contentResolver, Clock time, Looper looper) { mClock = time; mContentResolver = contentResolver; mHandler = new MyHandler(looper); @@ -175,9 +174,6 @@ public class ProgramDataManager implements MemoryManageable { } mContentResolver.registerContentObserver(Programs.CONTENT_URI, true, mProgramObserver); - if (mEpgFetcher != null && Experiments.CLOUD_EPG.get()) { - mEpgFetcher.start(); - } } /** @@ -190,10 +186,6 @@ public class ProgramDataManager implements MemoryManageable { return; } mStarted = false; - - if (mEpgFetcher != null) { - mEpgFetcher.stop(); - } mContentResolver.unregisterContentObserver(mProgramObserver); mHandler.removeCallbacksAndMessages(null); @@ -205,13 +197,23 @@ public class ProgramDataManager implements MemoryManageable { } } - /** - * Returns the current program at the specified channel. - */ + @AnyThread + public boolean isCurrentProgramsLoadFinished() { + return mCurrentProgramsLoadFinished; + } + + /** Returns the current program at the specified channel. */ + @AnyThread public Program getCurrentProgram(long channelId) { return mChannelIdCurrentProgramMap.get(channelId); } + /** Returns all the current programs. */ + @AnyThread + public List<Program> getCurrentPrograms() { + return new ArrayList<>(mChannelIdCurrentProgramMap.values()); + } + /** * Reloads program data. */ @@ -338,19 +340,19 @@ public class ProgramDataManager implements MemoryManageable { } private void notifyCurrentProgramUpdate(long channelId, Program program) { - for (OnCurrentProgramUpdatedListener listener : mChannelId2ProgramUpdatedListeners .get(channelId)) { listener.onCurrentProgramUpdated(channelId, program); - } + } for (OnCurrentProgramUpdatedListener listener : mChannelId2ProgramUpdatedListeners .get(Channel.INVALID_ID)) { listener.onCurrentProgramUpdated(channelId, program); - } + } } private void updateCurrentProgram(long channelId, Program program) { - Program previousProgram = mChannelIdCurrentProgramMap.put(channelId, program); + Program previousProgram = program == null ? mChannelIdCurrentProgramMap.remove(channelId) + : mChannelIdCurrentProgramMap.put(channelId, program); if (!Objects.equals(program, previousProgram)) { if (mPrefetchEnabled) { removePreviousProgramsAndUpdateCurrentProgramInCache(channelId, program); @@ -581,22 +583,22 @@ public class ProgramDataManager implements MemoryManageable { protected void onPostExecute(List<Program> programs) { if (DEBUG) Log.d(TAG, "ProgramsUpdateTask done"); mProgramsUpdateTask = null; - if (programs == null) { - return; - } - Set<Long> removedChannelIds = new HashSet<>(mChannelIdCurrentProgramMap.keySet()); - for (Program program : programs) { - long channelId = program.getChannelId(); - updateCurrentProgram(channelId, program); - removedChannelIds.remove(channelId); - } - for (Long channelId : removedChannelIds) { - if (mPrefetchEnabled) { - mChannelIdProgramCache.remove(channelId); + if (programs != null) { + Set<Long> removedChannelIds = new HashSet<>(mChannelIdCurrentProgramMap.keySet()); + for (Program program : programs) { + long channelId = program.getChannelId(); + updateCurrentProgram(channelId, program); + removedChannelIds.remove(channelId); + } + for (Long channelId : removedChannelIds) { + if (mPrefetchEnabled) { + mChannelIdProgramCache.remove(channelId); + } + mChannelIdCurrentProgramMap.remove(channelId); + notifyCurrentProgramUpdate(channelId, null); } - mChannelIdCurrentProgramMap.remove(channelId); - notifyCurrentProgramUpdate(channelId, null); } + mCurrentProgramsLoadFinished = true; } } diff --git a/src/com/android/tv/data/StreamInfo.java b/src/com/android/tv/data/StreamInfo.java index fe461f14..709863cf 100644 --- a/src/com/android/tv/data/StreamInfo.java +++ b/src/com/android/tv/data/StreamInfo.java @@ -38,5 +38,9 @@ public interface StreamInfo { int getAudioChannelCount(); boolean hasClosedCaption(); boolean isVideoAvailable(); + /** + * Returns true, if video or audio is available. + */ + boolean isVideoOrAudioAvailable(); int getVideoUnavailableReason(); } diff --git a/src/com/android/tv/data/WatchedHistoryManager.java b/src/com/android/tv/data/WatchedHistoryManager.java index 59319338..3edd7b1a 100644 --- a/src/com/android/tv/data/WatchedHistoryManager.java +++ b/src/com/android/tv/data/WatchedHistoryManager.java @@ -10,15 +10,14 @@ import android.os.Looper; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; +import android.support.annotation.WorkerThread; import android.util.Log; import com.android.tv.common.SharedPreferencesUtils; import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Scanner; import java.util.concurrent.TimeUnit; @@ -28,15 +27,15 @@ import java.util.concurrent.TimeUnit; * * <p>When there is no access to watched table of TvProvider, * this class is used to build up watched history and to compute recent channels. + * <p>Note that this class is not thread safe. Please use this on one thread. */ public class WatchedHistoryManager { private final static String TAG = "WatchedHistoryManager"; - private final boolean DEBUG = false; + private final static boolean DEBUG = false; private static final int MAX_HISTORY_SIZE = 10000; private static final String PREF_KEY_LAST_INDEX = "last_index"; private static final long MIN_DURATION_MS = TimeUnit.SECONDS.toMillis(10); - private static final long RECENT_CHANNEL_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5); private final List<WatchedRecord> mWatchedHistory = new ArrayList<>(); private final List<WatchedRecord> mPendingRecords = new ArrayList<>(); @@ -92,11 +91,7 @@ public class WatchedHistoryManager { WatchedHistoryManager(Context context, int maxHistorySize) { mContext = context.getApplicationContext(); mMaxHistorySize = maxHistorySize; - if (Looper.myLooper() == null) { - mHandler = new Handler(Looper.getMainLooper()); - } else { - mHandler = new Handler(); - } + mHandler = new Handler(); } /** @@ -107,56 +102,70 @@ public class WatchedHistoryManager { return; } mStarted = true; - new AsyncTask<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... params) { - mSharedPreferences = mContext.getSharedPreferences( - SharedPreferencesUtils.SHARED_PREF_WATCHED_HISTORY, Context.MODE_PRIVATE); - mLastIndex = mSharedPreferences.getLong(PREF_KEY_LAST_INDEX, -1); - if (mLastIndex >= 0 && mLastIndex < mMaxHistorySize) { - for (int i = 0; i <= mLastIndex; ++i) { - WatchedRecord record = - decode(mSharedPreferences.getString(getSharedPreferencesKey(i), - null)); - if (record != null) { - mWatchedHistory.add(record); - } - } - } else if (mLastIndex >= mMaxHistorySize) { - for (long i = mLastIndex - mMaxHistorySize + 1; i <= mLastIndex; ++i) { - WatchedRecord record = decode(mSharedPreferences.getString( - getSharedPreferencesKey(i), null)); - if (record != null) { - mWatchedHistory.add(record); - } - } + if (Looper.myLooper() == Looper.getMainLooper()) { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + loadWatchedHistory(); + return null; } - return null; - } - @Override - protected void onPostExecute(Void params) { - mLoaded = true; - if (DEBUG) { - Log.d(TAG, "Loaded: size=" + mWatchedHistory.size() + " index=" + mLastIndex); + @Override + protected void onPostExecute(Void params) { + onLoadFinished(); } - if (!mPendingRecords.isEmpty()) { - Editor editor = mSharedPreferences.edit(); - for (WatchedRecord record : mPendingRecords) { - mWatchedHistory.add(record); - ++mLastIndex; - editor.putString(getSharedPreferencesKey(mLastIndex), encode(record)); - } - editor.putLong(PREF_KEY_LAST_INDEX, mLastIndex).apply(); - mPendingRecords.clear(); + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + loadWatchedHistory(); + onLoadFinished(); + } + } + + @WorkerThread + private void loadWatchedHistory() { + mSharedPreferences = mContext.getSharedPreferences( + SharedPreferencesUtils.SHARED_PREF_WATCHED_HISTORY, Context.MODE_PRIVATE); + mLastIndex = mSharedPreferences.getLong(PREF_KEY_LAST_INDEX, -1); + if (mLastIndex >= 0 && mLastIndex < mMaxHistorySize) { + for (int i = 0; i <= mLastIndex; ++i) { + WatchedRecord record = + decode(mSharedPreferences.getString(getSharedPreferencesKey(i), + null)); + if (record != null) { + mWatchedHistory.add(record); } - if (mListener != null) { - mListener.onLoadFinished(); + } + } else if (mLastIndex >= mMaxHistorySize) { + for (long i = mLastIndex - mMaxHistorySize + 1; i <= mLastIndex; ++i) { + WatchedRecord record = decode(mSharedPreferences.getString( + getSharedPreferencesKey(i), null)); + if (record != null) { + mWatchedHistory.add(record); } - mSharedPreferences.registerOnSharedPreferenceChangeListener( - mOnSharedPreferenceChangeListener); } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + private void onLoadFinished() { + mLoaded = true; + if (DEBUG) { + Log.d(TAG, "Loaded: size=" + mWatchedHistory.size() + " index=" + mLastIndex); + } + if (!mPendingRecords.isEmpty()) { + Editor editor = mSharedPreferences.edit(); + for (WatchedRecord record : mPendingRecords) { + mWatchedHistory.add(record); + ++mLastIndex; + editor.putString(getSharedPreferencesKey(mLastIndex), encode(record)); + } + editor.putLong(PREF_KEY_LAST_INDEX, mLastIndex).apply(); + mPendingRecords.clear(); + } + if (mListener != null) { + mListener.onLoadFinished(); + } + mSharedPreferences.registerOnSharedPreferenceChangeListener( + mOnSharedPreferenceChangeListener); } @VisibleForTesting @@ -204,52 +213,6 @@ public class WatchedHistoryManager { return Collections.unmodifiableList(mWatchedHistory); } - /** - * Returns the list of recently watched channels. - */ - public List<Channel> buildRecentChannel(ChannelDataManager channelDataManager, int maxCount) { - List<Channel> list = new ArrayList<>(); - Map<Long, Long> durationMap = new HashMap<>(); - for (int i = mWatchedHistory.size() - 1; i >= 0; --i) { - WatchedRecord record = mWatchedHistory.get(i); - long channelId = record.channelId; - Channel channel = channelDataManager.getChannel(channelId); - if (channel == null || !channel.isBrowsable()) { - continue; - } - Long duration = durationMap.get(channelId); - if (duration == null) { - duration = 0L; - } - if (duration >= RECENT_CHANNEL_THRESHOLD_MS) { - continue; - } - if (list.isEmpty()) { - // We put the first recent channel regardless of RECENT_CHANNEL_THREASHOLD. - // It has the similar functionality as the previous channel in a usual remote - // controller. - list.add(channel); - durationMap.put(channelId, RECENT_CHANNEL_THRESHOLD_MS); - } else { - duration += record.duration; - durationMap.put(channelId, duration); - if (duration >= RECENT_CHANNEL_THRESHOLD_MS) { - list.add(channel); - } - } - if (list.size() >= maxCount) { - break; - } - } - if (DEBUG) { - Log.d(TAG, "Build recent channel"); - for (Channel channel : list) { - Log.d(TAG, "recent channel: " + channel); - } - } - return list; - } - @VisibleForTesting WatchedRecord getRecord(int reverseIndex) { return mWatchedHistory.get(mWatchedHistory.size() - 1 - reverseIndex); diff --git a/src/com/android/tv/data/epg/EpgFetchHelper.java b/src/com/android/tv/data/epg/EpgFetchHelper.java new file mode 100644 index 00000000..5693c877 --- /dev/null +++ b/src/com/android/tv/data/epg/EpgFetchHelper.java @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2017 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.epg; + +import android.content.ContentProviderOperation; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.media.tv.TvContract.Programs; +import android.os.RemoteException; +import android.preference.PreferenceManager; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; +import android.util.Log; + +import com.android.tv.data.Program; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** The helper class for {@link com.android.tv.data.epg.EpgFetcher} */ +class EpgFetchHelper { + private static final String TAG = "EpgFetchHelper"; + private static final boolean DEBUG = false; + + private static final long PROGRAM_QUERY_DURATION_MS = TimeUnit.DAYS.toMillis(30); + private static final int BATCH_OPERATION_COUNT = 100; + + // Value: Long + private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP = + "com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp"; + // Value: String + private static final String KEY_LAST_LINEUP_ID = + "com.android.tv.data.epg.EpgFetcher.LastLineupId"; + + private static long sLastEpgUpdatedTimestamp = -1; + private static String sLastLineupId; + + private EpgFetchHelper() { } + + /** + * Updates newly fetched EPG data for the given channel to local providers. The method will + * compare the broadcasting time and try to match each newly fetched program with old programs + * of that channel in the database one by one. It will update the matched old program, or insert + * the new program if there is no matching program can be found in the database and at the same + * time remove those old programs which conflicts with the inserted one. + + * @param channelId the target channel ID. + * @param fetchedPrograms the newly fetched program data. + * @return {@code true} if new program data are successfully updated. Otherwise {@code false}. + */ + static boolean updateEpgData(Context context, long channelId, List<Program> fetchedPrograms) { + final int fetchedProgramsCount = fetchedPrograms.size(); + if (fetchedProgramsCount == 0) { + return false; + } + boolean updated = false; + long startTimeMs = System.currentTimeMillis(); + long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION_MS; + List<Program> oldPrograms = queryPrograms(context, channelId, startTimeMs, endTimeMs); + int oldProgramsIndex = 0; + int newProgramsIndex = 0; + + // Compare the new programs with old programs one by one and update/delete the old one + // or insert new program if there is no matching program in the database. + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + while (newProgramsIndex < fetchedProgramsCount) { + Program oldProgram = oldProgramsIndex < oldPrograms.size() + ? oldPrograms.get(oldProgramsIndex) : null; + Program newProgram = fetchedPrograms.get(newProgramsIndex); + boolean addNewProgram = false; + if (oldProgram != null) { + if (oldProgram.equals(newProgram)) { + // Exact match. No need to update. Move on to the next programs. + oldProgramsIndex++; + newProgramsIndex++; + } else if (hasSameTitleAndOverlap(oldProgram, newProgram)) { + // Partial match. Update the old program with the new one. + // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There + // could be application specific settings which belong to the old program. + ops.add(ContentProviderOperation.newUpdate( + TvContract.buildProgramUri(oldProgram.getId())) + .withValues(Program.toContentValues(newProgram)) + .build()); + oldProgramsIndex++; + newProgramsIndex++; + } else if (oldProgram.getEndTimeUtcMillis() < newProgram.getEndTimeUtcMillis()) { + // No match. Remove the old program first to see if the next program in + // {@code oldPrograms} partially matches the new program. + ops.add(ContentProviderOperation.newDelete( + TvContract.buildProgramUri(oldProgram.getId())) + .build()); + oldProgramsIndex++; + } else { + // No match. The new program does not match any of the old programs. Insert + // it as a new program. + addNewProgram = true; + newProgramsIndex++; + } + } else { + // No old programs. Just insert new programs. + addNewProgram = true; + newProgramsIndex++; + } + if (addNewProgram) { + ops.add(ContentProviderOperation + .newInsert(Programs.CONTENT_URI) + .withValues(Program.toContentValues(newProgram)) + .build()); + } + // Throttle the batch operation not to cause TransactionTooLargeException. + if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) { + try { + if (DEBUG) { + int size = ops.size(); + Log.d(TAG, "Running " + size + " operations for channel " + channelId); + for (int i = 0; i < size; ++i) { + Log.d(TAG, "Operation(" + i + "): " + ops.get(i)); + } + } + context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); + updated = true; + } catch (RemoteException | OperationApplicationException e) { + Log.e(TAG, "Failed to insert programs.", e); + return updated; + } + ops.clear(); + } + } + if (DEBUG) { + Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId); + } + return updated; + } + + private static List<Program> queryPrograms(Context context, long channelId, + long startTimeMs, long endTimeMs) { + try (Cursor c = context.getContentResolver().query( + TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs), + Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) { + if (c == null) { + return Collections.emptyList(); + } + ArrayList<Program> programs = new ArrayList<>(); + while (c.moveToNext()) { + programs.add(Program.fromCursor(c)); + } + return programs; + } + } + + /** + * Returns {@code true} if the {@code oldProgram} needs to be updated with the + * {@code newProgram}. + */ + private static boolean hasSameTitleAndOverlap(Program oldProgram, Program newProgram) { + // NOTE: Here, we update the old program if it has the same title and overlaps with the + // new program. The test logic is just an example and you can modify this. E.g. check + // whether the both programs have the same program ID if your EPG supports any ID for + // the programs. + return TextUtils.equals(oldProgram.getTitle(), newProgram.getTitle()) + && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis() + && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis(); + } + + /** + * Sets the last known lineup ID into shared preferences for future usage. If channels are not + * re-scanned, EPG fetcher can directly use this value instead of checking the correct lineup ID + * every time when it needs to fetch EPG data. + */ + @WorkerThread + synchronized static void setLastLineupId(Context context, String lineupId) { + if (DEBUG) { + if (lineupId == null) { + Log.d(TAG, "Clear stored lineup id: " + sLastLineupId); + } + } + sLastLineupId = lineupId; + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(KEY_LAST_LINEUP_ID, lineupId).apply(); + } + + /** + * Gets the last known lineup ID from shared preferences. + */ + synchronized static String getLastLineupId(Context context) { + if (sLastLineupId == null) { + sLastLineupId = PreferenceManager.getDefaultSharedPreferences(context) + .getString(KEY_LAST_LINEUP_ID, null); + } + if (DEBUG) Log.d(TAG, "Last lineup is " + sLastLineupId); + return sLastLineupId; + } + + /** + * Sets the last updated timestamp of EPG data into shared preferences. If the EPG data is not + * out-dated, it's not necessary for EPG fetcher to fetch EPG again. + */ + @WorkerThread + synchronized static void setLastEpgUpdatedTimestamp(Context context, long timestamp) { + sLastEpgUpdatedTimestamp = timestamp; + PreferenceManager.getDefaultSharedPreferences(context).edit().putLong( + KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).apply(); + } + + /** + * Gets the last updated timestamp of EPG data. + */ + synchronized static long getLastEpgUpdatedTimestamp(Context context) { + if (sLastEpgUpdatedTimestamp < 0) { + sLastEpgUpdatedTimestamp = PreferenceManager.getDefaultSharedPreferences(context) + .getLong(KEY_LAST_UPDATED_EPG_TIMESTAMP, 0); + } + return sLastEpgUpdatedTimestamp; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/data/epg/EpgFetcher.java b/src/com/android/tv/data/epg/EpgFetcher.java index 3b093b6a..24f8b826 100644 --- a/src/com/android/tv/data/epg/EpgFetcher.java +++ b/src/com/android/tv/data/epg/EpgFetcher.java @@ -16,570 +16,720 @@ package com.android.tv.data.epg; -import android.Manifest; -import android.annotation.SuppressLint; -import android.content.ContentProviderOperation; -import android.content.ContentValues; +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; import android.content.Context; -import android.content.OperationApplicationException; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.location.Address; -import android.media.tv.TvContentRating; -import android.media.tv.TvContract; -import android.media.tv.TvContract.Programs; -import android.media.tv.TvContract.Programs.Genres; import android.media.tv.TvInputInfo; +import android.net.TrafficStats; +import android.os.AsyncTask; +import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; -import android.os.RemoteException; -import android.preference.PreferenceManager; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; -import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.v4.os.BuildCompat; +import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.util.Log; +import com.android.tv.ApplicationSingletons; +import com.android.tv.Features; import com.android.tv.TvApplication; -import com.android.tv.common.WeakHandler; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonUtils; +import com.android.tv.config.RemoteConfigUtils; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; -import com.android.tv.data.InternalDataUtils; +import com.android.tv.data.ChannelLogoFetcher; import com.android.tv.data.Lineup; import com.android.tv.data.Program; +import com.android.tv.perf.EventNames; +import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.TimerEvent; +import com.android.tv.tuner.util.PostalCodeUtils; import com.android.tv.util.LocationUtils; -import com.android.tv.util.RecurringRunner; +import com.android.tv.util.NetworkTrafficTags; import com.android.tv.util.Utils; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; -import java.util.Locale; -import java.util.Objects; +import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; /** - * An utility class to fetch the EPG. This class isn't thread-safe. + * The service class to fetch EPG routinely or on-demand during channel scanning + * + * <p>Since the default executor of {@link AsyncTask} is {@link AsyncTask#SERIAL_EXECUTOR}, only one + * task can run at a time. Because fetching EPG takes long time, the fetching task shouldn't run on + * the serial executor. Instead, it should run on the {@link AsyncTask#THREAD_POOL_EXECUTOR}. */ public class EpgFetcher { private static final String TAG = "EpgFetcher"; private static final boolean DEBUG = false; - private static final int MSG_FETCH_EPG = 1; + private static final int EPG_ROUTINELY_FETCHING_JOB_ID = 101; + + private static final long INITIAL_BACKOFF_MS = TimeUnit.SECONDS.toMillis(10); + + private static final int REASON_EPG_READER_NOT_READY = 1; + private static final int REASON_LOCATION_INFO_UNAVAILABLE = 2; + private static final int REASON_LOCATION_PERMISSION_NOT_GRANTED = 3; + private static final int REASON_NO_EPG_DATA_RETURNED = 4; + private static final int REASON_NO_NEW_EPG = 5; - private static final long EPG_PREFETCH_RECURRING_PERIOD_MS = TimeUnit.HOURS.toMillis(4); - private static final long EPG_READER_INIT_WAIT_MS = TimeUnit.MINUTES.toMillis(1); - private static final long LOCATION_INIT_WAIT_MS = TimeUnit.SECONDS.toMillis(10); - private static final long LOCATION_ERROR_WAIT_MS = TimeUnit.HOURS.toMillis(1); - private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(30); + private static final long FETCH_DURING_SCAN_WAIT_TIME_MS = TimeUnit.SECONDS.toMillis(10); - private static final int BATCH_OPERATION_COUNT = 100; + private static final long FETCH_DURING_SCAN_DURATION_SEC = TimeUnit.HOURS.toSeconds(3); + private static final long FAST_FETCH_DURATION_SEC = TimeUnit.DAYS.toSeconds(2); - private static final String SUPPORTED_COUNTRY_CODE = Locale.US.getCountry(); - private static final String CONTENT_RATING_SEPARATOR = ","; + private static final int DEFAULT_ROUTINE_INTERVAL_HOUR = 4; + private static final String KEY_ROUTINE_INTERVAL = "live_channels_epg_fetcher_interval_hour"; - // Value: Long - private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP = - "com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp"; - // Value: String - private static final String KEY_LAST_LINEUP_ID = - "com.android.tv.data.epg.EpgFetcher.LastLineupId"; + private static final int MSG_PREPARE_FETCH_DURING_SCAN = 1; + private static final int MSG_CHANNEL_UPDATED_DURING_SCAN = 2; + private static final int MSG_FINISH_FETCH_DURING_SCAN = 3; + private static final int MSG_RETRY_PREPARE_FETCH_DURING_SCAN = 4; + + private static final int QUERY_CHANNEL_COUNT = 50; + private static final int MINIMUM_CHANNELS_TO_DECIDE_LINEUP = 3; private static EpgFetcher sInstance; private final Context mContext; private final ChannelDataManager mChannelDataManager; private final EpgReader mEpgReader; - private EpgFetcherHandler mHandler; - private RecurringRunner mRecurringRunner; - private boolean mStarted; - - private long mLastEpgTimestamp = -1; - private String mLineupId; - - public static synchronized EpgFetcher getInstance(Context context) { + private final PerformanceMonitor mPerformanceMonitor; + private FetchAsyncTask mFetchTask; + private FetchDuringScanHandler mFetchDuringScanHandler; + private long mEpgTimeStamp; + private List<Lineup> mPossibleLineups; + private final Object mPossibleLineupsLock = new Object(); + private final Object mFetchDuringScanHandlerLock = new Object(); + // A flag to block the re-entrance of onChannelScanStarted and onChannelScanFinished. + private boolean mScanStarted; + + private final long mRoutineIntervalMs; + private final long mEpgDataExpiredTimeLimitMs; + private final long mFastFetchDurationSec; + + public static EpgFetcher getInstance(Context context) { if (sInstance == null) { - sInstance = new EpgFetcher(context.getApplicationContext()); + sInstance = new EpgFetcher(context); } return sInstance; } - /** - * Creates and returns {@link EpgReader}. - */ - public static EpgReader createEpgReader(Context context) { + /** Creates and returns {@link EpgReader}. */ + public static EpgReader createEpgReader(Context context, String region) { return new StubEpgReader(context); } private EpgFetcher(Context context) { - mContext = context; - mEpgReader = new StubEpgReader(mContext); - mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); - mChannelDataManager.addListener(new ChannelDataManager.Listener() { - @Override - public void onLoadFinished() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); - handleChannelChanged(); + mContext = context.getApplicationContext(); + ApplicationSingletons applicationSingletons = TvApplication.getSingletons(mContext); + mChannelDataManager = applicationSingletons.getChannelDataManager(); + mPerformanceMonitor = applicationSingletons.getPerformanceMonitor(); + mEpgReader = createEpgReader(mContext, LocationUtils.getCurrentCountry(mContext)); + + int remoteInteval = + (int) RemoteConfigUtils.getRemoteConfig( + context, KEY_ROUTINE_INTERVAL, DEFAULT_ROUTINE_INTERVAL_HOUR); + mRoutineIntervalMs = + remoteInteval < 0 + ? TimeUnit.HOURS.toMillis(DEFAULT_ROUTINE_INTERVAL_HOUR) + : TimeUnit.HOURS.toMillis(remoteInteval); + mEpgDataExpiredTimeLimitMs = mRoutineIntervalMs * 2; + mFastFetchDurationSec = FAST_FETCH_DURATION_SEC + mRoutineIntervalMs / 1000; + } + + /** + * Starts the routine service of EPG fetching. It use {@link JobScheduler} to schedule the EPG + * fetching routine. The EPG fetching routine will be started roughly every 4 hours, unless + * the channel scanning of tuner input is started. + */ + @MainThread + public void startRoutineService() { + JobScheduler jobScheduler = + (JobScheduler) mContext.getSystemService(Context.JOB_SCHEDULER_SERVICE); + for (JobInfo job : jobScheduler.getAllPendingJobs()) { + if (job.getId() == EPG_ROUTINELY_FETCHING_JOB_ID) { + return; } + } + JobInfo job = + new JobInfo.Builder( + EPG_ROUTINELY_FETCHING_JOB_ID, + new ComponentName(mContext, EpgFetchService.class)) + .setPeriodic(mRoutineIntervalMs) + .setBackoffCriteria(INITIAL_BACKOFF_MS, JobInfo.BACKOFF_POLICY_EXPONENTIAL) + .setPersisted(true) + .build(); + jobScheduler.schedule(job); + Log.i(TAG, "EPG fetching routine service started."); + } + /** + * Fetches EPG immediately if current EPG data are out-dated, i.e., not successfully updated + * by routine fetching service due to various reasons. + */ + @MainThread + public void fetchImmediatelyIfNeeded() { + if (TvCommonUtils.isRunningInTest()) { + // Do not run EpgFetcher in test. + return; + } + new AsyncTask<Void, Void, Long>() { @Override - public void onChannelListUpdated() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); - handleChannelChanged(); + protected Long doInBackground(Void... args) { + return EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext); } @Override - public void onChannelBrowsableChanged() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelBrowsableChanged()"); - handleChannelChanged(); + protected void onPostExecute(Long result) { + if (System.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) + > mEpgDataExpiredTimeLimitMs) { + Log.i(TAG, "EPG data expired. Start fetching immediately."); + fetchImmediately(); + } } - }); + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } - private void handleChannelChanged() { - if (mStarted) { - if (needToStop()) { - stop(); - } - } else { - start(); - } - } + /** + * Fetches EPG immediately. + */ + @MainThread + public void fetchImmediately() { + if (!mChannelDataManager.isDbLoadFinished()) { + mChannelDataManager.addListener(new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + mChannelDataManager.removeListener(this); + executeFetchTaskIfPossible(null, null); + } - private boolean needToStop() { - return !canStart(); - } + @Override + public void onChannelListUpdated() { } - private boolean canStart() { - if (DEBUG) Log.d(TAG, "canStart()"); - boolean hasInternalTunerChannel = false; - for (TvInputInfo input : TvApplication.getSingletons(mContext).getTvInputManagerHelper() - .getTvInputInfos(true, true)) { - String inputId = input.getId(); - if (Utils.isInternalTvInput(mContext, inputId) - && mChannelDataManager.getChannelCountForInput(inputId) > 0) { - hasInternalTunerChannel = true; - break; - } - } - if (!hasInternalTunerChannel) { - if (DEBUG) Log.d(TAG, "No internal tuner channels."); - return false; + @Override + public void onChannelBrowsableChanged() { } + }); + } else { + executeFetchTaskIfPossible(null, null); } + } - if (!TextUtils.isEmpty(getLastLineupId())) { - return true; - } - if (mContext.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) - != PackageManager.PERMISSION_GRANTED) { - if (DEBUG) Log.d(TAG, "No permission to check the current location."); - return false; + /** + * Notifies EPG fetch service that channel scanning is started. + */ + @MainThread + public void onChannelScanStarted() { + if (mScanStarted || !Features.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { + return; } - - try { - Address address = LocationUtils.getCurrentAddress(mContext); - if (address != null - && !TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) { - if (DEBUG) Log.d(TAG, "Country not supported: " + address.getCountryCode()); - return false; + mScanStarted = true; + stopFetchingJob(); + synchronized (mFetchDuringScanHandlerLock) { + if (mFetchDuringScanHandler == null) { + HandlerThread thread = new HandlerThread("EpgFetchDuringScan"); + thread.start(); + mFetchDuringScanHandler = new FetchDuringScanHandler(thread.getLooper()); } - } catch (SecurityException e) { - Log.w(TAG, "No permission to get the current location", e); - return false; - } catch (IOException e) { - Log.w(TAG, "IO Exception when getting the current location", e); + mFetchDuringScanHandler.sendEmptyMessage(MSG_PREPARE_FETCH_DURING_SCAN); } - return true; + Log.i(TAG, "EPG fetching on channel scanning started."); } /** - * Starts fetching EPG. + * Notifies EPG fetch service that channel scanning is finished. */ @MainThread - public void start() { - if (DEBUG) Log.d(TAG, "start()"); - if (mStarted) { - if (DEBUG) Log.d(TAG, "EpgFetcher thread already started."); + public void onChannelScanFinished() { + if (!mScanStarted) { return; } - if (!canStart()) { - return; + mScanStarted = false; + mFetchDuringScanHandler.sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); + } + + @MainThread + private void stopFetchingJob() { + if (DEBUG) Log.d(TAG, "Try to stop routinely fetching job..."); + if (mFetchTask != null) { + mFetchTask.cancel(true); + mFetchTask = null; + Log.i(TAG, "EPG routinely fetching job stopped."); } - mStarted = true; - if (DEBUG) Log.d(TAG, "Starting EpgFetcher thread."); - HandlerThread handlerThread = new HandlerThread("EpgFetcher"); - handlerThread.start(); - mHandler = new EpgFetcherHandler(handlerThread.getLooper(), this); - mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS, - new EpgRunner(), null); - mRecurringRunner.start(); - if (DEBUG) Log.d(TAG, "EpgFetcher thread started successfully."); } - /** - * Starts fetching EPG immediately if possible without waiting for the timer. - */ @MainThread - public void startImmediately() { - start(); - if (mStarted) { - if (DEBUG) Log.d(TAG, "Starting fetcher immediately"); - fetchEpg(); + private boolean executeFetchTaskIfPossible(JobService service, JobParameters params) { + SoftPreconditions.checkState(mChannelDataManager.isDbLoadFinished()); + if (!TvCommonUtils.isRunningInTest() && checkFetchPrerequisite()) { + mFetchTask = new FetchAsyncTask(service, params); + mFetchTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + return true; } + return false; } - /** - * Stops fetching EPG. - */ @MainThread - public void stop() { - if (DEBUG) Log.d(TAG, "stop()"); - if (!mStarted) { - return; + private boolean checkFetchPrerequisite() { + if (DEBUG) Log.d(TAG, "Check prerequisite of routinely fetching job."); + if (!Features.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { + Log.i(TAG, "Cannot start routine service: country not supported: " + + LocationUtils.getCurrentCountry(mContext)); + return false; + } + if (mFetchTask != null) { + // Fetching job is already running or ready to run, no need to start again. + return false; + } + if (mFetchDuringScanHandler != null) { + if (DEBUG) Log.d(TAG, "Cannot start routine service: scanning channels."); + return false; + } + if (getTunerChannelCount() == 0) { + if (DEBUG) Log.d(TAG, "Cannot start routine service: no internal tuner channels."); + return false; } - mStarted = false; - mRecurringRunner.stop(); - mHandler.removeCallbacksAndMessages(null); - mHandler.getLooper().quit(); + if (!TextUtils.isEmpty(EpgFetchHelper.getLastLineupId(mContext))) { + return true; + } + if (!TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return true; + } + return true; } - private void fetchEpg() { - fetchEpg(0); + @MainThread + private int getTunerChannelCount() { + for (TvInputInfo input : TvApplication.getSingletons(mContext) + .getTvInputManagerHelper().getTvInputInfos(true, true)) { + String inputId = input.getId(); + if (Utils.isInternalTvInput(mContext, inputId)) { + return mChannelDataManager.getChannelCountForInput(inputId); + } + } + return 0; } - private void fetchEpg(long delay) { - mHandler.removeMessages(MSG_FETCH_EPG); - mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, delay); + @AnyThread + private void clearUnusedLineups(@Nullable String lineupId) { + synchronized (mPossibleLineupsLock) { + if (mPossibleLineups == null) { + return; + } + for (Lineup lineup : mPossibleLineups) { + if (!TextUtils.equals(lineupId, lineup.id)) { + mEpgReader.clearCachedChannels(lineup.id); + } + } + mPossibleLineups = null; + } } - private void onFetchEpg() { - if (DEBUG) Log.d(TAG, "Start fetching EPG."); + @WorkerThread + private Integer prepareFetchEpg(boolean forceUpdatePossibleLineups) { if (!mEpgReader.isAvailable()) { - if (DEBUG) Log.d(TAG, "EPG reader is not temporarily available."); - fetchEpg(EPG_READER_INIT_WAIT_MS); - return; + Log.i(TAG, "EPG reader is temporarily unavailable."); + return REASON_EPG_READER_NOT_READY; } - String lineupId = getLastLineupId(); - if (lineupId == null) { - Address address; - try { - address = LocationUtils.getCurrentAddress(mContext); - } catch (IOException e) { - if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); - fetchEpg(LOCATION_ERROR_WAIT_MS); - return; - } catch (SecurityException e) { - Log.w(TAG, "No permission to get the current location."); - return; + // Checks the EPG Timestamp. + mEpgTimeStamp = mEpgReader.getEpgTimestamp(); + if (mEpgTimeStamp <= EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext)) { + if (DEBUG) Log.d(TAG, "No new EPG."); + return REASON_NO_NEW_EPG; + } + // Updates postal code. + boolean postalCodeChanged = false; + try { + postalCodeChanged = PostalCodeUtils.updatePostalCode(mContext); + } catch (IOException e) { + if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); + if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return REASON_LOCATION_INFO_UNAVAILABLE; } - if (address == null) { - if (DEBUG) Log.d(TAG, "Null address returned."); - fetchEpg(LOCATION_INIT_WAIT_MS); - return; + } catch (SecurityException e) { + Log.w(TAG, "No permission to get the current location."); + if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return REASON_LOCATION_PERMISSION_NOT_GRANTED; } - if (DEBUG) Log.d(TAG, "Current location is " + address); - - lineupId = getLineupForAddress(address); - if (lineupId != null) { - if (DEBUG) Log.d(TAG, "Saving lineup " + lineupId + "found for " + address); - setLastLineupId(lineupId); - } else { - if (DEBUG) Log.d(TAG, "No lineup found for " + address); - return; + } catch (PostalCodeUtils.NoPostalCodeException e) { + Log.i(TAG, "Cannot get address or postal code."); + return REASON_LOCATION_INFO_UNAVAILABLE; + } + // Updates possible lineups if necessary. + SoftPreconditions.checkState(mPossibleLineups == null, TAG, "Possible lineups not reset."); + if (postalCodeChanged || forceUpdatePossibleLineups + || EpgFetchHelper.getLastLineupId(mContext) == null) { + // To prevent main thread being blocked, though theoretically it should not happen. + List<Lineup> possibleLineups = + mEpgReader.getLineups(PostalCodeUtils.getLastPostalCode(mContext)); + if (possibleLineups.isEmpty()) { + return REASON_NO_EPG_DATA_RETURNED; + } + for (Lineup lineup : possibleLineups) { + mEpgReader.preloadChannels(lineup.id); + } + synchronized (mPossibleLineupsLock) { + mPossibleLineups = possibleLineups; } + EpgFetchHelper.setLastLineupId(mContext, null); } + return null; + } - // Check the EPG Timestamp. - long epgTimestamp = mEpgReader.getEpgTimestamp(); - if (epgTimestamp <= getLastUpdatedEpgTimestamp()) { - if (DEBUG) Log.d(TAG, "No new EPG."); + @WorkerThread + private void batchFetchEpg(List<Channel> channels, long durationSec) { + Log.i(TAG, "Start batch fetching (" + durationSec + ")...." + channels.size()); + if (channels.size() == 0) { return; } - - boolean updated = false; - List<Channel> channels = mEpgReader.getChannels(lineupId); + List<Long> queryChannelIds = new ArrayList<>(QUERY_CHANNEL_COUNT); for (Channel channel : channels) { - List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channel.getId())); - Collections.sort(programs); - if (DEBUG) { - Log.d(TAG, "Fetched " + programs.size() + " programs for channel " + channel); - } - if (updateEpg(channel.getId(), programs)) { - updated = true; + queryChannelIds.add(channel.getId()); + if (queryChannelIds.size() >= QUERY_CHANNEL_COUNT) { + batchUpdateEpg(mEpgReader.getPrograms(queryChannelIds, durationSec)); + queryChannelIds.clear(); } } - - final boolean epgUpdated = updated; - setLastUpdatedEpgTimestamp(epgTimestamp); - mHandler.removeMessages(MSG_FETCH_EPG); - if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); + if (!queryChannelIds.isEmpty()) { + batchUpdateEpg(mEpgReader.getPrograms(queryChannelIds, durationSec)); + } } - @Nullable - private String getLineupForAddress(Address address) { - String lineup = null; - if (TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) { - String postalCode = address.getPostalCode(); - if (!TextUtils.isEmpty(postalCode)) { - lineup = getLineupForPostalCode(postalCode); + @WorkerThread + private void batchUpdateEpg(Map<Long, List<Program>> allPrograms) { + for (Map.Entry<Long, List<Program>> entry : allPrograms.entrySet()) { + List<Program> programs = entry.getValue(); + if (programs == null) { + continue; } + Collections.sort(programs); + Log.i(TAG, "Batch fetched " + programs.size() + " programs for channel " + + entry.getKey()); + EpgFetchHelper.updateEpgData(mContext, entry.getKey(), programs); } - return lineup; } @Nullable - private String getLineupForPostalCode(String postalCode) { - List<Lineup> lineups = mEpgReader.getLineups(postalCode); - for (Lineup lineup : lineups) { - // TODO(EPG): handle more than OTA digital - if (lineup.type == Lineup.LINEUP_BROADCAST_DIGITAL) { - if (DEBUG) Log.d(TAG, "Setting lineup to " + lineup.name + "(" + lineup.id + ")"); - return lineup.id; + @WorkerThread + private String pickBestLineupId(List<Channel> currentChannelList) { + String maxLineupId = null; + synchronized (mPossibleLineupsLock) { + if (mPossibleLineups == null) { + return null; + } + int maxCount = 0; + for (Lineup lineup : mPossibleLineups) { + int count = getMatchedChannelCount(lineup.id, currentChannelList); + Log.i(TAG, lineup.name + " (" + lineup.id + ") - " + count + " matches"); + if (count > maxCount) { + maxCount = count; + maxLineupId = lineup.id; + } } } - return null; + return maxLineupId; } - private long getLastUpdatedEpgTimestamp() { - if (mLastEpgTimestamp < 0) { - mLastEpgTimestamp = PreferenceManager.getDefaultSharedPreferences(mContext).getLong( - KEY_LAST_UPDATED_EPG_TIMESTAMP, 0); + @WorkerThread + private int getMatchedChannelCount(String lineupId, List<Channel> currentChannelList) { + // Construct a list of display numbers for existing channels. + if (currentChannelList.isEmpty()) { + if (DEBUG) Log.d(TAG, "No existing channel to compare"); + return 0; + } + List<String> numbers = new ArrayList<>(currentChannelList.size()); + for (Channel channel : currentChannelList) { + // We only support channels from internal tuner inputs. + if (Utils.isInternalTvInput(mContext, channel.getInputId())) { + numbers.add(channel.getDisplayNumber()); + } } - return mLastEpgTimestamp; + numbers.retainAll(mEpgReader.getChannelNumbers(lineupId)); + return numbers.size(); } - private void setLastUpdatedEpgTimestamp(long timestamp) { - mLastEpgTimestamp = timestamp; - PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong( - KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).commit(); - } + public static class EpgFetchService extends JobService { + private EpgFetcher mEpgFetcher; - private String getLastLineupId() { - if (mLineupId == null) { - mLineupId = PreferenceManager.getDefaultSharedPreferences(mContext) - .getString(KEY_LAST_LINEUP_ID, null); + @Override + public void onCreate() { + super.onCreate(); + TvApplication.setCurrentRunningProcess(this, true); + mEpgFetcher = EpgFetcher.getInstance(this); } - if (DEBUG) Log.d(TAG, "Last lineup_id " + mLineupId); - return mLineupId; - } - private void setLastLineupId(String lineupId) { - mLineupId = lineupId; - PreferenceManager.getDefaultSharedPreferences(mContext).edit() - .putString(KEY_LAST_LINEUP_ID, lineupId).commit(); - } + @Override + public boolean onStartJob(JobParameters params) { + if (!mEpgFetcher.mChannelDataManager.isDbLoadFinished()) { + mEpgFetcher.mChannelDataManager.addListener(new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + mEpgFetcher.mChannelDataManager.removeListener(this); + if (!mEpgFetcher.executeFetchTaskIfPossible(EpgFetchService.this, params)) { + jobFinished(params, false); + } + } - private boolean updateEpg(long channelId, List<Program> newPrograms) { - final int fetchedProgramsCount = newPrograms.size(); - if (fetchedProgramsCount == 0) { + @Override + public void onChannelListUpdated() { } + + @Override + public void onChannelBrowsableChanged() { } + }); + return true; + } else { + return mEpgFetcher.executeFetchTaskIfPossible(this, params); + } + } + + @Override + public boolean onStopJob(JobParameters params) { + mEpgFetcher.stopFetchingJob(); return false; } - boolean updated = false; - long startTimeMs = System.currentTimeMillis(); - long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION; - List<Program> oldPrograms = queryPrograms(channelId, startTimeMs, endTimeMs); - Program currentOldProgram = oldPrograms.size() > 0 ? oldPrograms.get(0) : null; - int oldProgramsIndex = 0; - int newProgramsIndex = 0; - // Skip the past programs. They will be automatically removed by the system. - if (currentOldProgram != null) { - long oldStartTimeUtcMillis = currentOldProgram.getStartTimeUtcMillis(); - for (Program program : newPrograms) { - if (program.getEndTimeUtcMillis() > oldStartTimeUtcMillis) { - break; - } - newProgramsIndex++; - } + } + + private class FetchAsyncTask extends AsyncTask<Void, Void, Integer> { + private final JobService mService; + private final JobParameters mParams; + private List<Channel> mCurrentChannelList; + private TimerEvent mTimerEvent; + + private FetchAsyncTask(JobService service, JobParameters params) { + mService = service; + mParams = params; } - // Compare the new programs with old programs one by one and update/delete the old one - // or insert new program if there is no matching program in the database. - ArrayList<ContentProviderOperation> ops = new ArrayList<>(); - while (newProgramsIndex < fetchedProgramsCount) { - // TODO: Extract to method and make test. - Program oldProgram = oldProgramsIndex < oldPrograms.size() - ? oldPrograms.get(oldProgramsIndex) : null; - Program newProgram = newPrograms.get(newProgramsIndex); - boolean addNewProgram = false; - if (oldProgram != null) { - if (oldProgram.equals(newProgram)) { - // Exact match. No need to update. Move on to the next programs. - oldProgramsIndex++; - newProgramsIndex++; - } else if (isSameTitleAndOverlap(oldProgram, newProgram)) { - // Partial match. Update the old program with the new one. - // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There - // could be application specific settings which belong to the old program. - ops.add(ContentProviderOperation.newUpdate( - TvContract.buildProgramUri(oldProgram.getId())) - .withValues(toContentValues(newProgram)) - .build()); - oldProgramsIndex++; - newProgramsIndex++; - } else if (oldProgram.getEndTimeUtcMillis() - < newProgram.getEndTimeUtcMillis()) { - // No match. Remove the old program first to see if the next program in - // {@code oldPrograms} partially matches the new program. - ops.add(ContentProviderOperation.newDelete( - TvContract.buildProgramUri(oldProgram.getId())) - .build()); - oldProgramsIndex++; + + @Override + protected void onPreExecute() { + mTimerEvent = mPerformanceMonitor.startTimer(); + mCurrentChannelList = mChannelDataManager.getChannelList(); + } + + @Override + protected Integer doInBackground(Void... args) { + final int oldTag = TrafficStats.getThreadStatsTag(); + TrafficStats.setThreadStatsTag(NetworkTrafficTags.EPG_FETCH); + try { + if (DEBUG) Log.d(TAG, "Start EPG routinely fetching."); + Integer failureReason = prepareFetchEpg(false); + // InterruptedException might be caught by RPC, we should check it here. + if (failureReason != null || this.isCancelled()) { + return failureReason; + } + String lineupId = EpgFetchHelper.getLastLineupId(mContext); + lineupId = lineupId == null ? pickBestLineupId(mCurrentChannelList) : lineupId; + if (lineupId != null) { + Log.i(TAG, "Selecting the lineup " + lineupId); + // During normal fetching process, the lineup ID should be confirmed since all + // channels are known, clear up possible lineups to save resources. + EpgFetchHelper.setLastLineupId(mContext, lineupId); + clearUnusedLineups(lineupId); } else { - // No match. The new program does not match any of the old programs. Insert - // it as a new program. - addNewProgram = true; - newProgramsIndex++; + Log.i(TAG, "Failed to get lineup id"); + return REASON_NO_EPG_DATA_RETURNED; } - } else { - // No old programs. Just insert new programs. - addNewProgram = true; - newProgramsIndex++; - } - if (addNewProgram) { - ops.add(ContentProviderOperation - .newInsert(TvContract.Programs.CONTENT_URI) - .withValues(toContentValues(newProgram)) - .build()); - } - // Throttle the batch operation not to cause TransactionTooLargeException. - if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) { - try { - if (DEBUG) { - int size = ops.size(); - Log.d(TAG, "Running " + size + " operations for channel " + channelId); - for (int i = 0; i < size; ++i) { - Log.d(TAG, "Operation(" + i + "): " + ops.get(i)); - } + final List<Channel> channels = mEpgReader.getChannels(lineupId); + // InterruptedException might be caught by RPC, we should check it here. + if (this.isCancelled()) { + return null; + } + if (channels.isEmpty()) { + Log.i(TAG, "Failed to get EPG channels."); + return REASON_NO_EPG_DATA_RETURNED; + } + if (System.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) + > mEpgDataExpiredTimeLimitMs) { + batchFetchEpg(channels, mFastFetchDurationSec); + } + new Handler(mContext.getMainLooper()) + .post( + new Runnable() { + @Override + public void run() { + ChannelLogoFetcher.startFetchingChannelLogos( + mContext, channels); + } + }); + for (Channel channel : channels) { + if (this.isCancelled()) { + return null; } - mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); - updated = true; - } catch (RemoteException | OperationApplicationException e) { - Log.e(TAG, "Failed to insert programs.", e); - return updated; + long channelId = channel.getId(); + List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channelId)); + // InterruptedException might be caught by RPC, we should check it here. + Collections.sort(programs); + Log.i(TAG, "Fetched " + programs.size() + " programs for channel " + channelId); + EpgFetchHelper.updateEpgData(mContext, channelId, programs); } - ops.clear(); + EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, mEpgTimeStamp); + if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); + return null; + } finally { + TrafficStats.setThreadStatsTag(oldTag); } } - if (DEBUG) { - Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId); - } - return updated; - } - private List<Program> queryPrograms(long channelId, long startTimeMs, long endTimeMs) { - try (Cursor c = mContext.getContentResolver().query( - TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs), - Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) { - if (c == null) { - return Collections.emptyList(); - } - ArrayList<Program> programs = new ArrayList<>(); - while (c.moveToNext()) { - programs.add(Program.fromCursor(c)); + @Override + protected void onPostExecute(Integer failureReason) { + mFetchTask = null; + if (failureReason == null || failureReason == REASON_LOCATION_PERMISSION_NOT_GRANTED + || failureReason == REASON_NO_NEW_EPG) { + jobFinished(false); + } else { + // Applies back-off policy + jobFinished(true); } - return programs; + mPerformanceMonitor.stopTimer(mTimerEvent, EventNames.FETCH_EPG_TASK); + mPerformanceMonitor.recordMemory(EventNames.FETCH_EPG_TASK); } - } - /** - * Returns {@code true} if the {@code oldProgram} program needs to be updated with the - * {@code newProgram} program. - */ - private boolean isSameTitleAndOverlap(Program oldProgram, Program newProgram) { - // NOTE: Here, we update the old program if it has the same title and overlaps with the - // new program. The test logic is just an example and you can modify this. E.g. check - // whether the both programs have the same program ID if your EPG supports any ID for - // the programs. - return Objects.equals(oldProgram.getTitle(), newProgram.getTitle()) - && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis() - && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis(); - } + @Override + protected void onCancelled(Integer failureReason) { + clearUnusedLineups(null); + jobFinished(false); + } - @SuppressLint("InlinedApi") - @SuppressWarnings("deprecation") - private static ContentValues toContentValues(Program program) { - ContentValues values = new ContentValues(); - values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId()); - putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle()); - putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle()); - if (BuildCompat.isAtLeastN()) { - putValue(values, TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER, - program.getSeasonNumber()); - putValue(values, TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER, - program.getEpisodeNumber()); - } else { - putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber()); - putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber()); - } - putValue(values, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, program.getDescription()); - putValue(values, TvContract.Programs.COLUMN_POSTER_ART_URI, program.getPosterArtUri()); - putValue(values, TvContract.Programs.COLUMN_THUMBNAIL_URI, program.getThumbnailUri()); - String[] canonicalGenres = program.getCanonicalGenres(); - if (canonicalGenres != null && canonicalGenres.length > 0) { - putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, - Genres.encode(canonicalGenres)); - } else { - putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, ""); - } - TvContentRating[] ratings = program.getContentRatings(); - if (ratings != null && ratings.length > 0) { - StringBuilder sb = new StringBuilder(ratings[0].flattenToString()); - for (int i = 1; i < ratings.length; ++i) { - sb.append(CONTENT_RATING_SEPARATOR); - sb.append(ratings[i].flattenToString()); + private void jobFinished(boolean reschedule) { + if (mService != null && mParams != null) { + // Task is executed from JobService, need to report jobFinished. + mService.jobFinished(mParams, reschedule); } - putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, sb.toString()); - } else { - putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, ""); - } - values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, - program.getStartTimeUtcMillis()); - values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, program.getEndTimeUtcMillis()); - putValue(values, TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA, - InternalDataUtils.serializeInternalProviderData(program)); - return values; - } - - private static void putValue(ContentValues contentValues, String key, String value) { - if (TextUtils.isEmpty(value)) { - contentValues.putNull(key); - } else { - contentValues.put(key, value); } } - private static void putValue(ContentValues contentValues, String key, byte[] value) { - if (value == null || value.length == 0) { - contentValues.putNull(key); - } else { - contentValues.put(key, value); - } - } + @WorkerThread + private class FetchDuringScanHandler extends Handler { + private final Set<Long> mFetchedChannelIdsDuringScan = new HashSet<>(); + private String mPossibleLineupId; + + private final ChannelDataManager.Listener mDuringScanChannelListener = + new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); + if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP + && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + Message.obtain(FetchDuringScanHandler.this, + MSG_CHANNEL_UPDATED_DURING_SCAN, new ArrayList<>( + mChannelDataManager.getChannelList())).sendToTarget(); + } + } + + @Override + public void onChannelListUpdated() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); + if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP + && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + Message.obtain(FetchDuringScanHandler.this, + MSG_CHANNEL_UPDATED_DURING_SCAN, + mChannelDataManager.getChannelList()).sendToTarget(); + } + } + + @Override + public void onChannelBrowsableChanged() { + // Do nothing + } + }; - private static class EpgFetcherHandler extends WeakHandler<EpgFetcher> { - public EpgFetcherHandler (@NonNull Looper looper, EpgFetcher ref) { - super(looper, ref); + @AnyThread + private FetchDuringScanHandler(Looper looper) { + super(looper); } @Override - public void handleMessage(Message msg, @NonNull EpgFetcher epgFetcher) { + public void handleMessage(Message msg) { switch (msg.what) { - case MSG_FETCH_EPG: - epgFetcher.onFetchEpg(); + case MSG_PREPARE_FETCH_DURING_SCAN: + case MSG_RETRY_PREPARE_FETCH_DURING_SCAN: + onPrepareFetchDuringScan(); break; - default: - super.handleMessage(msg); + case MSG_CHANNEL_UPDATED_DURING_SCAN: + if (!hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + onChannelUpdatedDuringScan((List<Channel>) msg.obj); + } + break; + case MSG_FINISH_FETCH_DURING_SCAN: + removeMessages(MSG_RETRY_PREPARE_FETCH_DURING_SCAN); + if (hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); + } else { + onFinishFetchDuringScan(); + } break; } } - } - private class EpgRunner implements Runnable { - @Override - public void run() { - fetchEpg(); + private void onPrepareFetchDuringScan() { + Integer failureReason = prepareFetchEpg(true); + if (failureReason != null) { + sendEmptyMessageDelayed( + MSG_RETRY_PREPARE_FETCH_DURING_SCAN, FETCH_DURING_SCAN_WAIT_TIME_MS); + return; + } + mChannelDataManager.addListener(mDuringScanChannelListener); + } + + private void onChannelUpdatedDuringScan(List<Channel> currentChannelList) { + String lineupId = pickBestLineupId(currentChannelList); + Log.i(TAG, "Fast fetch channels for lineup ID: " + lineupId); + if (TextUtils.isEmpty(lineupId)) { + if (TextUtils.isEmpty(mPossibleLineupId)) { + return; + } + } else if (!TextUtils.equals(lineupId, mPossibleLineupId)) { + mFetchedChannelIdsDuringScan.clear(); + mPossibleLineupId = lineupId; + } + List<Long> currentChannelIds = new ArrayList<>(); + for (Channel channel : currentChannelList) { + currentChannelIds.add(channel.getId()); + } + mFetchedChannelIdsDuringScan.retainAll(currentChannelIds); + List<Channel> newChannels = new ArrayList<>(); + for (Channel channel : mEpgReader.getChannels(mPossibleLineupId)) { + if (!mFetchedChannelIdsDuringScan.contains(channel.getId())) { + newChannels.add(channel); + mFetchedChannelIdsDuringScan.add(channel.getId()); + } + } + batchFetchEpg(newChannels, FETCH_DURING_SCAN_DURATION_SEC); + } + + private void onFinishFetchDuringScan() { + mChannelDataManager.removeListener(mDuringScanChannelListener); + EpgFetchHelper.setLastLineupId(mContext, mPossibleLineupId); + clearUnusedLineups(null); + mFetchedChannelIdsDuringScan.clear(); + synchronized (mFetchDuringScanHandlerLock) { + if (!hasMessages(MSG_PREPARE_FETCH_DURING_SCAN)) { + removeCallbacksAndMessages(null); + getLooper().quit(); + mFetchDuringScanHandler = null; + } + } + // Clear timestamp to make routine service start right away. + EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, 0); + Log.i(TAG, "EPG Fetching during channel scanning finished."); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + fetchImmediately(); + } + }); } } } diff --git a/src/com/android/tv/data/epg/EpgReader.java b/src/com/android/tv/data/epg/EpgReader.java index 4f3b6f52..c5aeca27 100644 --- a/src/com/android/tv/data/epg/EpgReader.java +++ b/src/com/android/tv/data/epg/EpgReader.java @@ -16,15 +16,17 @@ package com.android.tv.data.epg; +import android.support.annotation.AnyThread; import android.support.annotation.NonNull; import android.support.annotation.WorkerThread; import com.android.tv.data.Channel; import com.android.tv.data.Lineup; import com.android.tv.data.Program; -import com.android.tv.dvr.SeriesInfo; +import com.android.tv.dvr.data.SeriesInfo; import java.util.List; +import java.util.Map; /** * An interface used to retrieve the EPG data. This class should be used in worker thread. @@ -42,25 +44,48 @@ public interface EpgReader { */ long getEpgTimestamp(); + /** Sets the region code. */ + void setRegionCode(String regionCode); + + /** Returns the lineups list. */ + List<Lineup> getLineups(@NonNull String postalCode); + /** - * Returns the channels list. + * Returns the list of channel numbers (unsorted) for the given lineup. The result is used to + * choose the most appropriate lineup among others by comparing the channel numbers of the + * existing channels on the device. + */ + List<String> getChannelNumbers(@NonNull String lineupId); + + /** + * Returns the list of channels for the given lineup. The returned channels should map into the + * existing channels on the device. This method is usually called after selecting the lineup. */ List<Channel> getChannels(@NonNull String lineupId); + /** Pre-loads and caches channels for a given lineup. */ + void preloadChannels(@NonNull String lineupId); + /** - * Returns the lineups list. + * Clears cached channels for a given lineup. */ - List<Lineup> getLineups(@NonNull String postalCode); + @AnyThread + void clearCachedChannels(@NonNull String lineupId); /** - * Returns the programs for the given channel. The result is sorted by the start time. - * Note that the {@code Program} doesn't have valid program ID because it's not retrieved from - * TvProvider. + * Returns the programs for the given channel. Must call {@link #getChannels(String)} + * beforehand. Note that the {@code Program} doesn't have valid program ID because it's not + * retrieved from TvProvider. */ List<Program> getPrograms(long channelId); /** - * Returns the series information for the given series ID. + * Returns the programs for the given channels. Note that the {@code Program} doesn't have valid + * program ID because it's not retrieved from TvProvider. This method is only used to get + * programs for a short duration typically. */ - SeriesInfo getSeriesInfo(String seriesId); -} + Map<Long, List<Program>> getPrograms(@NonNull List<Long> channelIds, long duration); + + /** Returns the series information for the given series ID. */ + SeriesInfo getSeriesInfo(@NonNull String seriesId); +}
\ No newline at end of file diff --git a/src/com/android/tv/data/epg/StubEpgReader.java b/src/com/android/tv/data/epg/StubEpgReader.java index 64093f89..ab6935ad 100644 --- a/src/com/android/tv/data/epg/StubEpgReader.java +++ b/src/com/android/tv/data/epg/StubEpgReader.java @@ -18,13 +18,15 @@ package com.android.tv.data.epg; import android.content.Context; +import android.support.annotation.NonNull; import com.android.tv.data.Channel; import com.android.tv.data.Lineup; import com.android.tv.data.Program; -import com.android.tv.dvr.SeriesInfo; +import com.android.tv.dvr.data.SeriesInfo; import java.util.Collections; import java.util.List; +import java.util.Map; /** * A stub class to read EPG. @@ -44,22 +46,47 @@ public class StubEpgReader implements EpgReader{ } @Override - public List<Channel> getChannels(String lineupId) { + public void setRegionCode(String regionCode) { + // Do nothing + } + + @Override + public List<Lineup> getLineups(@NonNull String postalCode) { + return Collections.emptyList(); + } + + @Override + public List<String> getChannelNumbers(@NonNull String lineupId) { return Collections.emptyList(); } @Override - public List<Lineup> getLineups(String postalCode) { + public List<Channel> getChannels(@NonNull String lineupId) { return Collections.emptyList(); } @Override + public void preloadChannels(@NonNull String lineupId) { + // Do nothing + } + + @Override + public void clearCachedChannels(@NonNull String lineupId) { + // Do nothing + } + + @Override public List<Program> getPrograms(long channelId) { return Collections.emptyList(); } @Override - public SeriesInfo getSeriesInfo(String seriesId) { + public Map<Long, List<Program>> getPrograms(@NonNull List<Long> channelIds, long duration) { + return Collections.emptyMap(); + } + + @Override + public SeriesInfo getSeriesInfo(@NonNull String seriesId) { return null; } -} +}
\ No newline at end of file |