/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tv.guide; import android.support.annotation.MainThread; import android.support.annotation.Nullable; import android.util.ArraySet; import android.util.Log; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.GenreItems; import com.android.tv.data.Program; import com.android.tv.data.ProgramDataManager; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.DvrScheduleManager.OnConflictStateChangeListener; import com.android.tv.dvr.ScheduledRecording; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; /** * Manages the channels and programs for the program guide. */ @MainThread public class ProgramManager { private static final String TAG = "ProgramManager"; private static final boolean DEBUG = false; /** * If the first entry's visible duration is shorter than this value, we clip the entry out. * Note: If this value is larger than 1 min, it could cause mismatches between the entry's * position and detailed view's time range. */ static final long FIRST_ENTRY_MIN_DURATION = TimeUnit.MINUTES.toMillis(1); private static final long INVALID_ID = -1; private final TvInputManagerHelper mTvInputManagerHelper; private final ChannelDataManager mChannelDataManager; private final ProgramDataManager mProgramDataManager; private final DvrDataManager mDvrDataManager; // Only set if DVR is enabled private final DvrScheduleManager mDvrScheduleManager; private long mStartUtcMillis; private long mEndUtcMillis; private long mFromUtcMillis; private long mToUtcMillis; /** * Entry for program guide table. An "entry" can be either an actual program or a gap between * programs. This is needed for {@link ProgramListAdapter} because * {@link android.support.v17.leanback.widget.HorizontalGridView} ignores margins between items. */ public static class TableEntry { /** Channel ID which this entry is included. */ public final long channelId; /** Program corresponding to the entry. {@code null} means that this entry is a gap. */ public final Program program; public final ScheduledRecording scheduledRecording; /** Start time of entry in UTC milliseconds. */ public final long entryStartUtcMillis; /** End time of entry in UTC milliseconds */ public final long entryEndUtcMillis; private final boolean mIsBlocked; private TableEntry(long channelId, long startUtcMillis, long endUtcMillis) { this(channelId, null, startUtcMillis, endUtcMillis, false); } private TableEntry(long channelId, long startUtcMillis, long endUtcMillis, boolean blocked) { this(channelId, null, null, startUtcMillis, endUtcMillis, blocked); } private TableEntry(long channelId, Program program, long entryStartUtcMillis, long entryEndUtcMillis, boolean isBlocked) { this(channelId, program, null, entryStartUtcMillis, entryEndUtcMillis, isBlocked); } private TableEntry(long channelId, Program program, ScheduledRecording scheduledRecording, long entryStartUtcMillis, long entryEndUtcMillis, boolean isBlocked) { this.channelId = channelId; this.program = program; this.scheduledRecording = scheduledRecording; this.entryStartUtcMillis = entryStartUtcMillis; this.entryEndUtcMillis = entryEndUtcMillis; mIsBlocked = isBlocked; } /** * A stable id useful for {@link android.support.v7.widget.RecyclerView.Adapter}. */ public long getId() { // using a negative entryEndUtcMillis keeps it from conflicting with program Id return program != null ? program.getId() : -entryEndUtcMillis; } /** * Returns true if this is a gap. */ public boolean isGap() { return !Program.isValid(program); } /** * Returns true if this channel is blocked. */ public boolean isBlocked() { return mIsBlocked; } /** * Returns true if this program is on the air. */ public boolean isCurrentProgram() { long current = System.currentTimeMillis(); return entryStartUtcMillis <= current && entryEndUtcMillis > current; } /** * Returns if this program has the genre. */ public boolean hasGenre(int genreId) { return !isGap() && program.hasGenre(genreId); } /** * Returns the width of table entry, in pixels. */ public int getWidth() { return GuideUtils.convertMillisToPixel(entryStartUtcMillis, entryEndUtcMillis); } @Override public String toString() { return "TableEntry{" + "hashCode=" + hashCode() + ", channelId=" + channelId + ", program=" + program + ", startTime=" + Utils.toTimeString(entryStartUtcMillis) + ", endTimeTime=" + Utils.toTimeString(entryEndUtcMillis) + "}"; } } private List mChannels = new ArrayList<>(); private final Map> mChannelIdEntriesMap = new HashMap<>(); private final List> mGenreChannelList = new ArrayList<>(); private final List mFilteredGenreIds = new ArrayList<>(); // Position of selected genre to filter channel list. private int mSelectedGenreId = GenreItems.ID_ALL_CHANNELS; // Channel list after applying genre filter. // Should be matched with mSelectedGenreId always. private List mFilteredChannels = mChannels; private boolean mChannelDataLoaded; private final Set mListeners = new ArraySet<>(); private final Set mTableEntriesUpdatedListeners = new ArraySet<>(); private final Set mTableEntryChangedListeners = new ArraySet<>(); private final DvrDataManager.OnDvrScheduleLoadFinishedListener mDvrLoadedListener = new DvrDataManager.OnDvrScheduleLoadFinishedListener() { @Override public void onDvrScheduleLoadFinished() { if (mChannelDataLoaded) { for (ScheduledRecording r : mDvrDataManager.getAllScheduledRecordings()) { mScheduledRecordingListener.onScheduledRecordingAdded(r); } } mDvrDataManager.removeDvrScheduleLoadFinishedListener(this); } }; private final ChannelDataManager.Listener mChannelDataManagerListener = new ChannelDataManager.Listener() { @Override public void onLoadFinished() { mChannelDataLoaded = true; updateChannels(false); } @Override public void onChannelListUpdated() { updateChannels(false); } @Override public void onChannelBrowsableChanged() { updateChannels(false); } }; private final ProgramDataManager.Listener mProgramDataManagerListener = new ProgramDataManager.Listener() { @Override public void onProgramUpdated() { updateTableEntries(true); } }; private final DvrDataManager.ScheduledRecordingListener mScheduledRecordingListener = new DvrDataManager.ScheduledRecordingListener() { @Override public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { for (ScheduledRecording schedule : scheduledRecordings) { TableEntry oldEntry = getTableEntry(schedule); if (oldEntry != null) { TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, schedule, oldEntry.entryStartUtcMillis, oldEntry.entryEndUtcMillis, oldEntry.isBlocked()); updateEntry(oldEntry, newEntry); } } } @Override public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { for (ScheduledRecording schedule : scheduledRecordings) { TableEntry oldEntry = getTableEntry(schedule); if (oldEntry != null) { TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, null, oldEntry.entryStartUtcMillis, oldEntry.entryEndUtcMillis, oldEntry.isBlocked()); updateEntry(oldEntry, newEntry); } } } @Override public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { for (ScheduledRecording schedule : scheduledRecordings) { TableEntry oldEntry = getTableEntry(schedule); if (oldEntry != null) { TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, schedule, oldEntry.entryStartUtcMillis, oldEntry.entryEndUtcMillis, oldEntry.isBlocked()); updateEntry(oldEntry, newEntry); } } } }; private final OnConflictStateChangeListener mOnConflictStateChangeListener = new OnConflictStateChangeListener() { @Override public void onConflictStateChange(boolean conflict, ScheduledRecording... schedules) { for (ScheduledRecording schedule : schedules) { TableEntry entry = getTableEntry(schedule); if (entry != null) { notifyTableEntryUpdated(entry); } } } }; public ProgramManager(TvInputManagerHelper tvInputManagerHelper, ChannelDataManager channelDataManager, ProgramDataManager programDataManager, @Nullable DvrDataManager dvrDataManager, @Nullable DvrScheduleManager dvrScheduleManager) { mTvInputManagerHelper = tvInputManagerHelper; mChannelDataManager = channelDataManager; mProgramDataManager = programDataManager; mDvrDataManager = dvrDataManager; mDvrScheduleManager = dvrScheduleManager; } public void programGuideVisibilityChanged(boolean visible) { mProgramDataManager.setPauseProgramUpdate(visible); if (visible) { mChannelDataManager.addListener(mChannelDataManagerListener); mProgramDataManager.addListener(mProgramDataManagerListener); if (mDvrDataManager != null) { if (!mDvrDataManager.isDvrScheduleLoadFinished()) { mDvrDataManager.addDvrScheduleLoadFinishedListener(mDvrLoadedListener); } mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener); } if (mDvrScheduleManager != null) { mDvrScheduleManager.addOnConflictStateChangeListener( mOnConflictStateChangeListener); } } else { mChannelDataManager.removeListener(mChannelDataManagerListener); mProgramDataManager.removeListener(mProgramDataManagerListener); if (mDvrDataManager != null) { mDvrDataManager.removeDvrScheduleLoadFinishedListener(mDvrLoadedListener); mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener); } if (mDvrScheduleManager != null) { mDvrScheduleManager.removeOnConflictStateChangeListener( mOnConflictStateChangeListener); } } } /** * Adds a {@link Listener}. */ public void addListener(Listener listener) { mListeners.add(listener); } /** * Registers a listener to be invoked when table entries are updated. */ public void addTableEntriesUpdatedListener(TableEntriesUpdatedListener listener) { mTableEntriesUpdatedListeners.add(listener); } /** * Registers a listener to be invoked when a table entry is changed. */ public void addTableEntryChangedListener(TableEntryChangedListener listener) { mTableEntryChangedListeners.add(listener); } /** * Removes a {@link Listener}. */ public void removeListener(Listener listener) { mListeners.remove(listener); } /** * Removes a previously installed table entries update listener. */ public void removeTableEntriesUpdatedListener(TableEntriesUpdatedListener listener) { mTableEntriesUpdatedListeners.remove(listener); } /** * Removes a previously installed table entry changed listener. */ public void removeTableEntryChangedListener(TableEntryChangedListener listener) { mTableEntryChangedListeners.remove(listener); } /** * Build genre filters based on the current programs. * This categories channels by its current program's canonical genres * and subsequent @{link resetChannelListWithGenre(int)} calls will reset channel list * with built channel list. * This is expected to be called whenever program guide is shown. */ public void buildGenreFilters() { if (DEBUG) Log.d(TAG, "buildGenreFilters"); mGenreChannelList.clear(); for (int i = 0; i < GenreItems.getGenreCount(); i++) { mGenreChannelList.add(new ArrayList<>()); } for (Channel channel : mChannels) { // TODO: Use programs in visible area instead of using current programs only. Program currentProgram = mProgramDataManager.getCurrentProgram(channel.getId()); if (currentProgram != null && currentProgram.getCanonicalGenres() != null) { for (String genre : currentProgram.getCanonicalGenres()) { mGenreChannelList.get(GenreItems.getId(genre)).add(channel); } } } mGenreChannelList.set(GenreItems.ID_ALL_CHANNELS, mChannels); mFilteredGenreIds.clear(); mFilteredGenreIds.add(0); for (int i = 1; i < GenreItems.getGenreCount(); i++) { if (mGenreChannelList.get(i).size() > 0) { mFilteredGenreIds.add(i); } } mSelectedGenreId = GenreItems.ID_ALL_CHANNELS; mFilteredChannels = mChannels; notifyGenresUpdated(); } /** * Resets channel list with given genre. * Caller should call {@link #buildGenreFilters()} prior to call this API to make * This notifies channel updates to listeners. */ public void resetChannelListWithGenre(int genreId) { if (genreId == mSelectedGenreId) { return; } mFilteredChannels = mGenreChannelList.get(genreId); mSelectedGenreId = genreId; if (DEBUG) { Log.d(TAG, "resetChannelListWithGenre: " + GenreItems.getCanonicalGenre(genreId) + " has " + mFilteredChannels.size() + " channels out of " + mChannels.size()); } if (mGenreChannelList.get(mSelectedGenreId) == null) { throw new IllegalStateException("Genre filter isn't ready."); } notifyChannelsUpdated(); } /** * Returns list genre ID's which has a channel. */ public List getFilteredGenreIds() { return mFilteredGenreIds; } public int getSelectedGenreId() { return mSelectedGenreId; } // Note that This can be happens only if program guide isn't shown // because an user has to select channels as browsable through UI. private void updateChannels(boolean clearPreviousTableEntries) { if (DEBUG) Log.d(TAG, "updateChannels"); mChannels = mChannelDataManager.getBrowsableChannelList(); mSelectedGenreId = GenreItems.ID_ALL_CHANNELS; mFilteredChannels = mChannels; notifyChannelsUpdated(); updateTableEntries(clearPreviousTableEntries); } private void updateTableEntries(boolean clear) { if (clear) { mChannelIdEntriesMap.clear(); } boolean parentalControlsEnabled = mTvInputManagerHelper.getParentalControlSettings() .isParentalControlsEnabled(); for (Channel channel : mChannels) { long channelId = channel.getId(); // Inline the updating of the mChannelIdEntriesMap here so we can only call // getParentalControlSettings once. List entries = createProgramEntries(channelId, parentalControlsEnabled); mChannelIdEntriesMap.put(channelId, entries); int size = entries.size(); if (DEBUG) { Log.d(TAG, "Programs are loaded for channel " + channel.getId() + ", loaded size = " + size); } if (size == 0) { continue; } TableEntry lastEntry = entries.get(size - 1); if (mEndUtcMillis < lastEntry.entryEndUtcMillis && lastEntry.entryEndUtcMillis != Long.MAX_VALUE) { mEndUtcMillis = lastEntry.entryEndUtcMillis; } } if (mEndUtcMillis > mStartUtcMillis) { for (Channel channel : mChannels) { long channelId = channel.getId(); List entries = mChannelIdEntriesMap.get(channelId); if (entries.isEmpty()) { entries.add(new TableEntry(channelId, mStartUtcMillis, mEndUtcMillis)); } else { TableEntry lastEntry = entries.get(entries.size() - 1); if (mEndUtcMillis > lastEntry.entryEndUtcMillis) { entries.add(new TableEntry(channelId, lastEntry.entryEndUtcMillis, mEndUtcMillis)); } else if (lastEntry.entryEndUtcMillis == Long.MAX_VALUE) { entries.remove(entries.size() - 1); entries.add(new TableEntry(lastEntry.channelId, lastEntry.program, lastEntry.scheduledRecording, lastEntry.entryStartUtcMillis, mEndUtcMillis, lastEntry.mIsBlocked)); } } } } notifyTableEntriesUpdated(); buildGenreFilters(); } private void notifyGenresUpdated() { for (Listener listener : mListeners) { listener.onGenresUpdated(); } } private void notifyChannelsUpdated() { for (Listener listener : mListeners) { listener.onChannelsUpdated(); } } private void notifyTimeRangeUpdated() { for (Listener listener : mListeners) { listener.onTimeRangeUpdated(); } } private void notifyTableEntriesUpdated() { for (TableEntriesUpdatedListener listener : mTableEntriesUpdatedListeners) { listener.onTableEntriesUpdated(); } } private void notifyTableEntryUpdated(TableEntry entry) { for (TableEntryChangedListener listener : mTableEntryChangedListeners) { listener.onTableEntryChanged(entry); } } private void updateEntry(TableEntry old, TableEntry newEntry) { List entries = mChannelIdEntriesMap.get(old.channelId); int index = entries.indexOf(old); entries.set(index, newEntry); notifyTableEntryUpdated(newEntry); } @Nullable private TableEntry getTableEntry(ScheduledRecording scheduledRecording) { return getTableEntry(scheduledRecording.getChannelId(), scheduledRecording.getProgramId()); } @Nullable private TableEntry getTableEntry(long channelId, long entryId) { List entries = mChannelIdEntriesMap.get(channelId); if (entries != null) { for (TableEntry entry : entries) { if (entry.getId() == entryId) { return entry; } } } return null; } /** * Returns the start time of currently managed time range, in UTC millisecond. */ public long getFromUtcMillis() { return mFromUtcMillis; } /** * Returns the end time of currently managed time range, in UTC millisecond. */ public long getToUtcMillis() { return mToUtcMillis; } /** * Update the initial time range to manage. It updates program entries and genre as well. */ public void updateInitialTimeRange(long startUtcMillis, long endUtcMillis) { mStartUtcMillis = startUtcMillis; if (endUtcMillis > mEndUtcMillis) { mEndUtcMillis = endUtcMillis; } mProgramDataManager.setPrefetchTimeRange(mStartUtcMillis); updateChannels(true); setTimeRange(startUtcMillis, endUtcMillis); } private void setTimeRange(long fromUtcMillis, long toUtcMillis) { if (DEBUG) { Log.d(TAG, "setTimeRange. {FromTime=" + Utils.toTimeString(fromUtcMillis) + ", ToTime=" + Utils.toTimeString(toUtcMillis) + "}"); } if (mFromUtcMillis != fromUtcMillis || mToUtcMillis != toUtcMillis) { mFromUtcMillis = fromUtcMillis; mToUtcMillis = toUtcMillis; notifyTimeRangeUpdated(); } } /** * Returns the number of the currently managed channels. */ public int getChannelCount() { return mFilteredChannels.size(); } /** * Returns a {@link Channel} at a given {@code channelIndex} of the currently managed channels. * Returns {@code null} if such a channel is not found. */ public Channel getChannel(int channelIndex) { if (channelIndex < 0 || channelIndex >= getChannelCount()) { return null; } return mFilteredChannels.get(channelIndex); } /** * Returns the index of provided {@link Channel} within the currently managed channels. * Returns -1 if such a channel is not found. */ public int getChannelIndex(Channel channel) { return mFilteredChannels.indexOf(channel); } /** * Returns the index of channel with {@code channelId} within the currently managed channels. * Returns -1 if such a channel is not found. */ public int getChannelIndex(long channelId) { return getChannelIndex(mChannelDataManager.getChannel(channelId)); } /** * Returns the number of "entries", which lies within the currently managed time range, for a * given {@code channelId}. */ public int getTableEntryCount(long channelId) { return mChannelIdEntriesMap.get(channelId).size(); } /** * Returns an entry as {@link Program} for a given {@code channelId} and {@code index} of * entries within the currently managed time range. Returned {@link Program} can be a dummy one * (e.g., whose channelId is INVALID_ID), when it corresponds to a gap between programs. */ public TableEntry getTableEntry(long channelId, int index) { return mChannelIdEntriesMap.get(channelId).get(index); } private List createProgramEntries(long channelId, boolean parentalControlsEnabled) { List entries = new ArrayList<>(); boolean channelLocked = parentalControlsEnabled && mChannelDataManager.getChannel(channelId).isLocked(); if (channelLocked) { entries.add(new TableEntry(channelId, mStartUtcMillis, Long.MAX_VALUE, true)); } else { long lastProgramEndTime = mStartUtcMillis; List programs = mProgramDataManager.getPrograms(channelId, mStartUtcMillis); for (Program program : programs) { if (program.getChannelId() == INVALID_ID) { // Dummy program. continue; } long programStartTime = Math.max(program.getStartTimeUtcMillis(), mStartUtcMillis); long programEndTime = program.getEndTimeUtcMillis(); if (programStartTime > lastProgramEndTime) { // Gap since the last program. entries.add(new TableEntry(channelId, lastProgramEndTime, programStartTime)); lastProgramEndTime = programStartTime; } if (programEndTime > lastProgramEndTime) { ScheduledRecording scheduledRecording = mDvrDataManager == null ? null : mDvrDataManager.getScheduledRecordingForProgramId(program.getId()); entries.add(new TableEntry(channelId, program, scheduledRecording, lastProgramEndTime, programEndTime, false)); lastProgramEndTime = programEndTime; } } } if (entries.size() > 1) { TableEntry secondEntry = entries.get(1); if (secondEntry.entryStartUtcMillis < mStartUtcMillis + FIRST_ENTRY_MIN_DURATION) { // If the first entry's width doesn't have enough width, it is not good to show // the first entry from UI perspective. So we clip it out. entries.remove(0); entries.set(0, new TableEntry(secondEntry.channelId, secondEntry.program, secondEntry.scheduledRecording, mStartUtcMillis, secondEntry.entryEndUtcMillis, secondEntry.mIsBlocked)); } } return entries; } public interface Listener { void onGenresUpdated(); void onChannelsUpdated(); void onTimeRangeUpdated(); } public interface TableEntriesUpdatedListener { void onTableEntriesUpdated(); } public interface TableEntryChangedListener { void onTableEntryChanged(TableEntry entry); } public static class ListenerAdapter implements Listener { @Override public void onGenresUpdated() { } @Override public void onChannelsUpdated() { } @Override public void onTimeRangeUpdated() { } } /** * Shifts the time range by the given time. Also makes ProgramGuide scroll the views. */ public void shiftTime(long timeMillisToScroll) { long fromUtcMillis = mFromUtcMillis + timeMillisToScroll; long toUtcMillis = mToUtcMillis + timeMillisToScroll; if (fromUtcMillis < mStartUtcMillis) { fromUtcMillis = mStartUtcMillis; toUtcMillis += mStartUtcMillis - fromUtcMillis; } if (toUtcMillis > mEndUtcMillis) { fromUtcMillis -= toUtcMillis - mEndUtcMillis; toUtcMillis = mEndUtcMillis; } setTimeRange(fromUtcMillis, toUtcMillis); } /** * Returned the scrolled(shifted) time in milliseconds. */ public long getShiftedTime() { return mFromUtcMillis - mStartUtcMillis; } /** * Returns the start time set by {@link #updateInitialTimeRange}. */ public long getStartTime() { return mStartUtcMillis; } /** * Returns the program index of the program with {@code entryId} or -1 if not found. */ public int getProgramIdIndex(long channelId, long entryId) { List entries = mChannelIdEntriesMap.get(channelId); if (entries != null) { for (int i = 0; i < entries.size(); i++) { if (entries.get(i).getId() == entryId) { return i; } } } return -1; } /** * Returns the program index of the program at {@code time} or -1 if not found. */ public int getProgramIndexAtTime(long channelId, long time) { List entries = mChannelIdEntriesMap.get(channelId); for (int i = 0; i < entries.size(); ++i) { TableEntry entry = entries.get(i); if (entry.entryStartUtcMillis <= time && time < entry.entryEndUtcMillis) { return i; } } return -1; } }