Home | History | Annotate | Download | only in guide
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.tv.guide;
     18 
     19 import android.support.annotation.MainThread;
     20 import android.support.annotation.Nullable;
     21 import android.support.annotation.VisibleForTesting;
     22 import android.util.ArraySet;
     23 import android.util.Log;
     24 import com.android.tv.data.ChannelDataManager;
     25 import com.android.tv.data.GenreItems;
     26 import com.android.tv.data.Program;
     27 import com.android.tv.data.ProgramDataManager;
     28 import com.android.tv.data.api.Channel;
     29 import com.android.tv.dvr.DvrDataManager;
     30 import com.android.tv.dvr.DvrScheduleManager;
     31 import com.android.tv.dvr.DvrScheduleManager.OnConflictStateChangeListener;
     32 import com.android.tv.dvr.data.ScheduledRecording;
     33 import com.android.tv.util.TvInputManagerHelper;
     34 import com.android.tv.util.Utils;
     35 import java.util.ArrayList;
     36 import java.util.HashMap;
     37 import java.util.List;
     38 import java.util.Map;
     39 import java.util.Set;
     40 import java.util.concurrent.TimeUnit;
     41 
     42 /** Manages the channels and programs for the program guide. */
     43 @MainThread
     44 public class ProgramManager {
     45     private static final String TAG = "ProgramManager";
     46     private static final boolean DEBUG = false;
     47 
     48     /**
     49      * If the first entry's visible duration is shorter than this value, we clip the entry out.
     50      * Note: If this value is larger than 1 min, it could cause mismatches between the entry's
     51      * position and detailed view's time range.
     52      */
     53     static final long FIRST_ENTRY_MIN_DURATION = TimeUnit.MINUTES.toMillis(1);
     54 
     55     private static final long INVALID_ID = -1;
     56 
     57     private final TvInputManagerHelper mTvInputManagerHelper;
     58     private final ChannelDataManager mChannelDataManager;
     59     private final ProgramDataManager mProgramDataManager;
     60     private final DvrDataManager mDvrDataManager; // Only set if DVR is enabled
     61     private final DvrScheduleManager mDvrScheduleManager;
     62 
     63     private long mStartUtcMillis;
     64     private long mEndUtcMillis;
     65     private long mFromUtcMillis;
     66     private long mToUtcMillis;
     67 
     68     private List<Channel> mChannels = new ArrayList<>();
     69     private final Map<Long, List<TableEntry>> mChannelIdEntriesMap = new HashMap<>();
     70     private final List<List<Channel>> mGenreChannelList = new ArrayList<>();
     71     private final List<Integer> mFilteredGenreIds = new ArrayList<>();
     72 
     73     // Position of selected genre to filter channel list.
     74     private int mSelectedGenreId = GenreItems.ID_ALL_CHANNELS;
     75     // Channel list after applying genre filter.
     76     // Should be matched with mSelectedGenreId always.
     77     private List<Channel> mFilteredChannels = mChannels;
     78     private boolean mChannelDataLoaded;
     79 
     80     private final Set<Listener> mListeners = new ArraySet<>();
     81     private final Set<TableEntriesUpdatedListener> mTableEntriesUpdatedListeners = new ArraySet<>();
     82 
     83     private final Set<TableEntryChangedListener> mTableEntryChangedListeners = new ArraySet<>();
     84 
     85     private final DvrDataManager.OnDvrScheduleLoadFinishedListener mDvrLoadedListener =
     86             new DvrDataManager.OnDvrScheduleLoadFinishedListener() {
     87                 @Override
     88                 public void onDvrScheduleLoadFinished() {
     89                     if (mChannelDataLoaded) {
     90                         for (ScheduledRecording r : mDvrDataManager.getAllScheduledRecordings()) {
     91                             mScheduledRecordingListener.onScheduledRecordingAdded(r);
     92                         }
     93                     }
     94                     mDvrDataManager.removeDvrScheduleLoadFinishedListener(this);
     95                 }
     96             };
     97 
     98     private final ChannelDataManager.Listener mChannelDataManagerListener =
     99             new ChannelDataManager.Listener() {
    100                 @Override
    101                 public void onLoadFinished() {
    102                     mChannelDataLoaded = true;
    103                     updateChannels(false);
    104                 }
    105 
    106                 @Override
    107                 public void onChannelListUpdated() {
    108                     updateChannels(false);
    109                 }
    110 
    111                 @Override
    112                 public void onChannelBrowsableChanged() {
    113                     updateChannels(false);
    114                 }
    115             };
    116 
    117     private final ProgramDataManager.Listener mProgramDataManagerListener =
    118             new ProgramDataManager.Listener() {
    119                 @Override
    120                 public void onProgramUpdated() {
    121                     updateTableEntries(true);
    122                 }
    123             };
    124 
    125     private final DvrDataManager.ScheduledRecordingListener mScheduledRecordingListener =
    126             new DvrDataManager.ScheduledRecordingListener() {
    127                 @Override
    128                 public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
    129                     for (ScheduledRecording schedule : scheduledRecordings) {
    130                         TableEntry oldEntry = getTableEntry(schedule);
    131                         if (oldEntry != null) {
    132                             TableEntry newEntry =
    133                                     new TableEntry(
    134                                             oldEntry.channelId,
    135                                             oldEntry.program,
    136                                             schedule,
    137                                             oldEntry.entryStartUtcMillis,
    138                                             oldEntry.entryEndUtcMillis,
    139                                             oldEntry.isBlocked());
    140                             updateEntry(oldEntry, newEntry);
    141                         }
    142                     }
    143                 }
    144 
    145                 @Override
    146                 public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
    147                     for (ScheduledRecording schedule : scheduledRecordings) {
    148                         TableEntry oldEntry = getTableEntry(schedule);
    149                         if (oldEntry != null) {
    150                             TableEntry newEntry =
    151                                     new TableEntry(
    152                                             oldEntry.channelId,
    153                                             oldEntry.program,
    154                                             null,
    155                                             oldEntry.entryStartUtcMillis,
    156                                             oldEntry.entryEndUtcMillis,
    157                                             oldEntry.isBlocked());
    158                             updateEntry(oldEntry, newEntry);
    159                         }
    160                     }
    161                 }
    162 
    163                 @Override
    164                 public void onScheduledRecordingStatusChanged(
    165                         ScheduledRecording... scheduledRecordings) {
    166                     for (ScheduledRecording schedule : scheduledRecordings) {
    167                         TableEntry oldEntry = getTableEntry(schedule);
    168                         if (oldEntry != null) {
    169                             TableEntry newEntry =
    170                                     new TableEntry(
    171                                             oldEntry.channelId,
    172                                             oldEntry.program,
    173                                             schedule,
    174                                             oldEntry.entryStartUtcMillis,
    175                                             oldEntry.entryEndUtcMillis,
    176                                             oldEntry.isBlocked());
    177                             updateEntry(oldEntry, newEntry);
    178                         }
    179                     }
    180                 }
    181             };
    182 
    183     private final OnConflictStateChangeListener mOnConflictStateChangeListener =
    184             new OnConflictStateChangeListener() {
    185                 @Override
    186                 public void onConflictStateChange(
    187                         boolean conflict, ScheduledRecording... schedules) {
    188                     for (ScheduledRecording schedule : schedules) {
    189                         TableEntry entry = getTableEntry(schedule);
    190                         if (entry != null) {
    191                             notifyTableEntryUpdated(entry);
    192                         }
    193                     }
    194                 }
    195             };
    196 
    197     public ProgramManager(
    198             TvInputManagerHelper tvInputManagerHelper,
    199             ChannelDataManager channelDataManager,
    200             ProgramDataManager programDataManager,
    201             @Nullable DvrDataManager dvrDataManager,
    202             @Nullable DvrScheduleManager dvrScheduleManager) {
    203         mTvInputManagerHelper = tvInputManagerHelper;
    204         mChannelDataManager = channelDataManager;
    205         mProgramDataManager = programDataManager;
    206         mDvrDataManager = dvrDataManager;
    207         mDvrScheduleManager = dvrScheduleManager;
    208     }
    209 
    210     void programGuideVisibilityChanged(boolean visible) {
    211         mProgramDataManager.setPauseProgramUpdate(visible);
    212         if (visible) {
    213             mChannelDataManager.addListener(mChannelDataManagerListener);
    214             mProgramDataManager.addListener(mProgramDataManagerListener);
    215             if (mDvrDataManager != null) {
    216                 if (!mDvrDataManager.isDvrScheduleLoadFinished()) {
    217                     mDvrDataManager.addDvrScheduleLoadFinishedListener(mDvrLoadedListener);
    218                 }
    219                 mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener);
    220             }
    221             if (mDvrScheduleManager != null) {
    222                 mDvrScheduleManager.addOnConflictStateChangeListener(
    223                         mOnConflictStateChangeListener);
    224             }
    225         } else {
    226             mChannelDataManager.removeListener(mChannelDataManagerListener);
    227             mProgramDataManager.removeListener(mProgramDataManagerListener);
    228             if (mDvrDataManager != null) {
    229                 mDvrDataManager.removeDvrScheduleLoadFinishedListener(mDvrLoadedListener);
    230                 mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener);
    231             }
    232             if (mDvrScheduleManager != null) {
    233                 mDvrScheduleManager.removeOnConflictStateChangeListener(
    234                         mOnConflictStateChangeListener);
    235             }
    236         }
    237     }
    238 
    239     /** Adds a {@link Listener}. */
    240     void addListener(Listener listener) {
    241         mListeners.add(listener);
    242     }
    243 
    244     /** Registers a listener to be invoked when table entries are updated. */
    245     void addTableEntriesUpdatedListener(TableEntriesUpdatedListener listener) {
    246         mTableEntriesUpdatedListeners.add(listener);
    247     }
    248 
    249     /** Registers a listener to be invoked when a table entry is changed. */
    250     void addTableEntryChangedListener(TableEntryChangedListener listener) {
    251         mTableEntryChangedListeners.add(listener);
    252     }
    253 
    254     /** Removes a {@link Listener}. */
    255     void removeListener(Listener listener) {
    256         mListeners.remove(listener);
    257     }
    258 
    259     /** Removes a previously installed table entries update listener. */
    260     void removeTableEntriesUpdatedListener(TableEntriesUpdatedListener listener) {
    261         mTableEntriesUpdatedListeners.remove(listener);
    262     }
    263 
    264     /** Removes a previously installed table entry changed listener. */
    265     void removeTableEntryChangedListener(TableEntryChangedListener listener) {
    266         mTableEntryChangedListeners.remove(listener);
    267     }
    268 
    269     /**
    270      * Resets channel list with given genre. Caller should call {@link #buildGenreFilters()} prior
    271      * to call this API to make This notifies channel updates to listeners.
    272      */
    273     void resetChannelListWithGenre(int genreId) {
    274         if (genreId == mSelectedGenreId) {
    275             return;
    276         }
    277         mFilteredChannels = mGenreChannelList.get(genreId);
    278         mSelectedGenreId = genreId;
    279         if (DEBUG) {
    280             Log.d(
    281                     TAG,
    282                     "resetChannelListWithGenre: "
    283                             + GenreItems.getCanonicalGenre(genreId)
    284                             + " has "
    285                             + mFilteredChannels.size()
    286                             + " channels out of "
    287                             + mChannels.size());
    288         }
    289         if (mGenreChannelList.get(mSelectedGenreId) == null) {
    290             throw new IllegalStateException("Genre filter isn't ready.");
    291         }
    292         notifyChannelsUpdated();
    293     }
    294 
    295     /** Update the initial time range to manage. It updates program entries and genre as well. */
    296     void updateInitialTimeRange(long startUtcMillis, long endUtcMillis) {
    297         mStartUtcMillis = startUtcMillis;
    298         if (endUtcMillis > mEndUtcMillis) {
    299             mEndUtcMillis = endUtcMillis;
    300         }
    301 
    302         mProgramDataManager.setPrefetchTimeRange(mStartUtcMillis);
    303         updateChannels(true);
    304         setTimeRange(startUtcMillis, endUtcMillis);
    305     }
    306 
    307     /** Shifts the time range by the given time. Also makes ProgramGuide scroll the views. */
    308     void shiftTime(long timeMillisToScroll) {
    309         long fromUtcMillis = mFromUtcMillis + timeMillisToScroll;
    310         long toUtcMillis = mToUtcMillis + timeMillisToScroll;
    311         if (fromUtcMillis < mStartUtcMillis) {
    312             fromUtcMillis = mStartUtcMillis;
    313             toUtcMillis += mStartUtcMillis - fromUtcMillis;
    314         }
    315         if (toUtcMillis > mEndUtcMillis) {
    316             fromUtcMillis -= toUtcMillis - mEndUtcMillis;
    317             toUtcMillis = mEndUtcMillis;
    318         }
    319         setTimeRange(fromUtcMillis, toUtcMillis);
    320     }
    321 
    322     /** Returned the scrolled(shifted) time in milliseconds. */
    323     long getShiftedTime() {
    324         return mFromUtcMillis - mStartUtcMillis;
    325     }
    326 
    327     /** Returns the start time set by {@link #updateInitialTimeRange}. */
    328     long getStartTime() {
    329         return mStartUtcMillis;
    330     }
    331 
    332     /** Returns the program index of the program with {@code entryId} or -1 if not found. */
    333     int getProgramIdIndex(long channelId, long entryId) {
    334         List<TableEntry> entries = mChannelIdEntriesMap.get(channelId);
    335         if (entries != null) {
    336             for (int i = 0; i < entries.size(); i++) {
    337                 if (entries.get(i).getId() == entryId) {
    338                     return i;
    339                 }
    340             }
    341         }
    342         return -1;
    343     }
    344 
    345     /** Returns the program index of the program at {@code time} or -1 if not found. */
    346     int getProgramIndexAtTime(long channelId, long time) {
    347         List<TableEntry> entries = mChannelIdEntriesMap.get(channelId);
    348         for (int i = 0; i < entries.size(); ++i) {
    349             TableEntry entry = entries.get(i);
    350             if (entry.entryStartUtcMillis <= time && time < entry.entryEndUtcMillis) {
    351                 return i;
    352             }
    353         }
    354         return -1;
    355     }
    356 
    357     /** Returns the start time of currently managed time range, in UTC millisecond. */
    358     long getFromUtcMillis() {
    359         return mFromUtcMillis;
    360     }
    361 
    362     /** Returns the end time of currently managed time range, in UTC millisecond. */
    363     long getToUtcMillis() {
    364         return mToUtcMillis;
    365     }
    366 
    367     /** Returns the number of the currently managed channels. */
    368     int getChannelCount() {
    369         return mFilteredChannels.size();
    370     }
    371 
    372     /**
    373      * Returns a {@link Channel} at a given {@code channelIndex} of the currently managed channels.
    374      * Returns {@code null} if such a channel is not found.
    375      */
    376     Channel getChannel(int channelIndex) {
    377         if (channelIndex < 0 || channelIndex >= getChannelCount()) {
    378             return null;
    379         }
    380         return mFilteredChannels.get(channelIndex);
    381     }
    382 
    383     /**
    384      * Returns the index of provided {@link Channel} within the currently managed channels. Returns
    385      * -1 if such a channel is not found.
    386      */
    387     int getChannelIndex(Channel channel) {
    388         return mFilteredChannels.indexOf(channel);
    389     }
    390 
    391     /**
    392      * Returns the index of channel with {@code channelId} within the currently managed channels.
    393      * Returns -1 if such a channel is not found.
    394      */
    395     int getChannelIndex(long channelId) {
    396         return getChannelIndex(mChannelDataManager.getChannel(channelId));
    397     }
    398 
    399     /**
    400      * Returns the number of "entries", which lies within the currently managed time range, for a
    401      * given {@code channelId}.
    402      */
    403     int getTableEntryCount(long channelId) {
    404         return mChannelIdEntriesMap.get(channelId).size();
    405     }
    406 
    407     /**
    408      * Returns an entry as {@link Program} for a given {@code channelId} and {@code index} of
    409      * entries within the currently managed time range. Returned {@link Program} can be a dummy one
    410      * (e.g., whose channelId is INVALID_ID), when it corresponds to a gap between programs.
    411      */
    412     TableEntry getTableEntry(long channelId, int index) {
    413         return mChannelIdEntriesMap.get(channelId).get(index);
    414     }
    415 
    416     /** Returns list genre ID's which has a channel. */
    417     List<Integer> getFilteredGenreIds() {
    418         return mFilteredGenreIds;
    419     }
    420 
    421     int getSelectedGenreId() {
    422         return mSelectedGenreId;
    423     }
    424 
    425     // Note that This can be happens only if program guide isn't shown
    426     // because an user has to select channels as browsable through UI.
    427     private void updateChannels(boolean clearPreviousTableEntries) {
    428         if (DEBUG) Log.d(TAG, "updateChannels");
    429         mChannels = mChannelDataManager.getBrowsableChannelList();
    430         mSelectedGenreId = GenreItems.ID_ALL_CHANNELS;
    431         mFilteredChannels = mChannels;
    432         updateTableEntriesWithoutNotification(clearPreviousTableEntries);
    433         // Channel update notification should be called after updating table entries, so that
    434         // the listener can get the entries.
    435         notifyChannelsUpdated();
    436         notifyTableEntriesUpdated();
    437         buildGenreFilters();
    438     }
    439 
    440     private void updateTableEntries(boolean clear) {
    441         updateTableEntriesWithoutNotification(clear);
    442         notifyTableEntriesUpdated();
    443         buildGenreFilters();
    444     }
    445 
    446     /** Updates the table entries without notifying the change. */
    447     private void updateTableEntriesWithoutNotification(boolean clear) {
    448         if (clear) {
    449             mChannelIdEntriesMap.clear();
    450         }
    451         boolean parentalControlsEnabled =
    452                 mTvInputManagerHelper.getParentalControlSettings().isParentalControlsEnabled();
    453         for (Channel channel : mChannels) {
    454             long channelId = channel.getId();
    455             // Inline the updating of the mChannelIdEntriesMap here so we can only call
    456             // getParentalControlSettings once.
    457             List<TableEntry> entries = createProgramEntries(channelId, parentalControlsEnabled);
    458             mChannelIdEntriesMap.put(channelId, entries);
    459 
    460             int size = entries.size();
    461             if (DEBUG) {
    462                 Log.d(
    463                         TAG,
    464                         "Programs are loaded for channel "
    465                                 + channel.getId()
    466                                 + ", loaded size = "
    467                                 + size);
    468             }
    469             if (size == 0) {
    470                 continue;
    471             }
    472             TableEntry lastEntry = entries.get(size - 1);
    473             if (mEndUtcMillis < lastEntry.entryEndUtcMillis
    474                     && lastEntry.entryEndUtcMillis != Long.MAX_VALUE) {
    475                 mEndUtcMillis = lastEntry.entryEndUtcMillis;
    476             }
    477         }
    478         if (mEndUtcMillis > mStartUtcMillis) {
    479             for (Channel channel : mChannels) {
    480                 long channelId = channel.getId();
    481                 List<TableEntry> entries = mChannelIdEntriesMap.get(channelId);
    482                 if (entries.isEmpty()) {
    483                     entries.add(new TableEntry(channelId, mStartUtcMillis, mEndUtcMillis));
    484                 } else {
    485                     TableEntry lastEntry = entries.get(entries.size() - 1);
    486                     if (mEndUtcMillis > lastEntry.entryEndUtcMillis) {
    487                         entries.add(
    488                                 new TableEntry(
    489                                         channelId, lastEntry.entryEndUtcMillis, mEndUtcMillis));
    490                     } else if (lastEntry.entryEndUtcMillis == Long.MAX_VALUE) {
    491                         entries.remove(entries.size() - 1);
    492                         entries.add(
    493                                 new TableEntry(
    494                                         lastEntry.channelId,
    495                                         lastEntry.program,
    496                                         lastEntry.scheduledRecording,
    497                                         lastEntry.entryStartUtcMillis,
    498                                         mEndUtcMillis,
    499                                         lastEntry.mIsBlocked));
    500                     }
    501                 }
    502             }
    503         }
    504     }
    505 
    506     /**
    507      * Build genre filters based on the current programs. This categories channels by its current
    508      * program's canonical genres and subsequent @{link resetChannelListWithGenre(int)} calls will
    509      * reset channel list with built channel list. This is expected to be called whenever program
    510      * guide is shown.
    511      */
    512     private void buildGenreFilters() {
    513         if (DEBUG) Log.d(TAG, "buildGenreFilters");
    514 
    515         mGenreChannelList.clear();
    516         for (int i = 0; i < GenreItems.getGenreCount(); i++) {
    517             mGenreChannelList.add(new ArrayList<>());
    518         }
    519         for (Channel channel : mChannels) {
    520             Program currentProgram = mProgramDataManager.getCurrentProgram(channel.getId());
    521             if (currentProgram != null && currentProgram.getCanonicalGenres() != null) {
    522                 for (String genre : currentProgram.getCanonicalGenres()) {
    523                     mGenreChannelList.get(GenreItems.getId(genre)).add(channel);
    524                 }
    525             }
    526         }
    527         mGenreChannelList.set(GenreItems.ID_ALL_CHANNELS, mChannels);
    528         mFilteredGenreIds.clear();
    529         mFilteredGenreIds.add(0);
    530         for (int i = 1; i < GenreItems.getGenreCount(); i++) {
    531             if (mGenreChannelList.get(i).size() > 0) {
    532                 mFilteredGenreIds.add(i);
    533             }
    534         }
    535         mSelectedGenreId = GenreItems.ID_ALL_CHANNELS;
    536         mFilteredChannels = mChannels;
    537         notifyGenresUpdated();
    538     }
    539 
    540     @Nullable
    541     private TableEntry getTableEntry(ScheduledRecording scheduledRecording) {
    542         return getTableEntry(scheduledRecording.getChannelId(), scheduledRecording.getProgramId());
    543     }
    544 
    545     @Nullable
    546     private TableEntry getTableEntry(long channelId, long entryId) {
    547         List<TableEntry> entries = mChannelIdEntriesMap.get(channelId);
    548         if (entries != null) {
    549             for (TableEntry entry : entries) {
    550                 if (entry.getId() == entryId) {
    551                     return entry;
    552                 }
    553             }
    554         }
    555         return null;
    556     }
    557 
    558     private void updateEntry(TableEntry old, TableEntry newEntry) {
    559         List<TableEntry> entries = mChannelIdEntriesMap.get(old.channelId);
    560         int index = entries.indexOf(old);
    561         entries.set(index, newEntry);
    562         notifyTableEntryUpdated(newEntry);
    563     }
    564 
    565     private void setTimeRange(long fromUtcMillis, long toUtcMillis) {
    566         if (DEBUG) {
    567             Log.d(
    568                     TAG,
    569                     "setTimeRange. {FromTime="
    570                             + Utils.toTimeString(fromUtcMillis)
    571                             + ", ToTime="
    572                             + Utils.toTimeString(toUtcMillis)
    573                             + "}");
    574         }
    575         if (mFromUtcMillis != fromUtcMillis || mToUtcMillis != toUtcMillis) {
    576             mFromUtcMillis = fromUtcMillis;
    577             mToUtcMillis = toUtcMillis;
    578             notifyTimeRangeUpdated();
    579         }
    580     }
    581 
    582     private List<TableEntry> createProgramEntries(long channelId, boolean parentalControlsEnabled) {
    583         List<TableEntry> entries = new ArrayList<>();
    584         boolean channelLocked =
    585                 parentalControlsEnabled && mChannelDataManager.getChannel(channelId).isLocked();
    586         if (channelLocked) {
    587             entries.add(new TableEntry(channelId, mStartUtcMillis, Long.MAX_VALUE, true));
    588         } else {
    589             long lastProgramEndTime = mStartUtcMillis;
    590             List<Program> programs = mProgramDataManager.getPrograms(channelId, mStartUtcMillis);
    591             for (Program program : programs) {
    592                 if (program.getChannelId() == INVALID_ID) {
    593                     // Dummy program.
    594                     continue;
    595                 }
    596                 long programStartTime = Math.max(program.getStartTimeUtcMillis(), mStartUtcMillis);
    597                 long programEndTime = program.getEndTimeUtcMillis();
    598                 if (programStartTime > lastProgramEndTime) {
    599                     // Gap since the last program.
    600                     entries.add(new TableEntry(channelId, lastProgramEndTime, programStartTime));
    601                     lastProgramEndTime = programStartTime;
    602                 }
    603                 if (programEndTime > lastProgramEndTime) {
    604                     ScheduledRecording scheduledRecording =
    605                             mDvrDataManager == null
    606                                     ? null
    607                                     : mDvrDataManager.getScheduledRecordingForProgramId(
    608                                             program.getId());
    609                     entries.add(
    610                             new TableEntry(
    611                                     channelId,
    612                                     program,
    613                                     scheduledRecording,
    614                                     lastProgramEndTime,
    615                                     programEndTime,
    616                                     false));
    617                     lastProgramEndTime = programEndTime;
    618                 }
    619             }
    620         }
    621 
    622         if (entries.size() > 1) {
    623             TableEntry secondEntry = entries.get(1);
    624             if (secondEntry.entryStartUtcMillis < mStartUtcMillis + FIRST_ENTRY_MIN_DURATION) {
    625                 // If the first entry's width doesn't have enough width, it is not good to show
    626                 // the first entry from UI perspective. So we clip it out.
    627                 entries.remove(0);
    628                 entries.set(
    629                         0,
    630                         new TableEntry(
    631                                 secondEntry.channelId,
    632                                 secondEntry.program,
    633                                 secondEntry.scheduledRecording,
    634                                 mStartUtcMillis,
    635                                 secondEntry.entryEndUtcMillis,
    636                                 secondEntry.mIsBlocked));
    637             }
    638         }
    639         return entries;
    640     }
    641 
    642     private void notifyGenresUpdated() {
    643         for (Listener listener : mListeners) {
    644             listener.onGenresUpdated();
    645         }
    646     }
    647 
    648     private void notifyChannelsUpdated() {
    649         for (Listener listener : mListeners) {
    650             listener.onChannelsUpdated();
    651         }
    652     }
    653 
    654     private void notifyTimeRangeUpdated() {
    655         for (Listener listener : mListeners) {
    656             listener.onTimeRangeUpdated();
    657         }
    658     }
    659 
    660     private void notifyTableEntriesUpdated() {
    661         for (TableEntriesUpdatedListener listener : mTableEntriesUpdatedListeners) {
    662             listener.onTableEntriesUpdated();
    663         }
    664     }
    665 
    666     private void notifyTableEntryUpdated(TableEntry entry) {
    667         for (TableEntryChangedListener listener : mTableEntryChangedListeners) {
    668             listener.onTableEntryChanged(entry);
    669         }
    670     }
    671 
    672     /**
    673      * Entry for program guide table. An "entry" can be either an actual program or a gap between
    674      * programs. This is needed for {@link ProgramListAdapter} because {@link
    675      * android.support.v17.leanback.widget.HorizontalGridView} ignores margins between items.
    676      */
    677     static class TableEntry {
    678         /** Channel ID which this entry is included. */
    679         final long channelId;
    680 
    681         /** Program corresponding to the entry. {@code null} means that this entry is a gap. */
    682         final Program program;
    683 
    684         final ScheduledRecording scheduledRecording;
    685 
    686         /** Start time of entry in UTC milliseconds. */
    687         final long entryStartUtcMillis;
    688 
    689         /** End time of entry in UTC milliseconds */
    690         final long entryEndUtcMillis;
    691 
    692         private final boolean mIsBlocked;
    693 
    694         private TableEntry(long channelId, long startUtcMillis, long endUtcMillis) {
    695             this(channelId, null, startUtcMillis, endUtcMillis, false);
    696         }
    697 
    698         private TableEntry(
    699                 long channelId, long startUtcMillis, long endUtcMillis, boolean blocked) {
    700             this(channelId, null, null, startUtcMillis, endUtcMillis, blocked);
    701         }
    702 
    703         private TableEntry(
    704                 long channelId,
    705                 Program program,
    706                 long entryStartUtcMillis,
    707                 long entryEndUtcMillis,
    708                 boolean isBlocked) {
    709             this(channelId, program, null, entryStartUtcMillis, entryEndUtcMillis, isBlocked);
    710         }
    711 
    712         private TableEntry(
    713                 long channelId,
    714                 Program program,
    715                 ScheduledRecording scheduledRecording,
    716                 long entryStartUtcMillis,
    717                 long entryEndUtcMillis,
    718                 boolean isBlocked) {
    719             this.channelId = channelId;
    720             this.program = program;
    721             this.scheduledRecording = scheduledRecording;
    722             this.entryStartUtcMillis = entryStartUtcMillis;
    723             this.entryEndUtcMillis = entryEndUtcMillis;
    724             mIsBlocked = isBlocked;
    725         }
    726 
    727         /** A stable id useful for {@link android.support.v7.widget.RecyclerView.Adapter}. */
    728         long getId() {
    729             // using a negative entryEndUtcMillis keeps it from conflicting with program Id
    730             return program != null ? program.getId() : -entryEndUtcMillis;
    731         }
    732 
    733         /** Returns true if this is a gap. */
    734         boolean isGap() {
    735             return !Program.isProgramValid(program);
    736         }
    737 
    738         /** Returns true if this channel is blocked. */
    739         boolean isBlocked() {
    740             return mIsBlocked;
    741         }
    742 
    743         /** Returns true if this program is on the air. */
    744         boolean isCurrentProgram() {
    745             long current = System.currentTimeMillis();
    746             return entryStartUtcMillis <= current && entryEndUtcMillis > current;
    747         }
    748 
    749         /** Returns if this program has the genre. */
    750         boolean hasGenre(int genreId) {
    751             return !isGap() && program.hasGenre(genreId);
    752         }
    753 
    754         /** Returns the width of table entry, in pixels. */
    755         int getWidth() {
    756             return GuideUtils.convertMillisToPixel(entryStartUtcMillis, entryEndUtcMillis);
    757         }
    758 
    759         @Override
    760         public String toString() {
    761             return "TableEntry{"
    762                     + "hashCode="
    763                     + hashCode()
    764                     + ", channelId="
    765                     + channelId
    766                     + ", program="
    767                     + program
    768                     + ", startTime="
    769                     + Utils.toTimeString(entryStartUtcMillis)
    770                     + ", endTimeTime="
    771                     + Utils.toTimeString(entryEndUtcMillis)
    772                     + "}";
    773         }
    774     }
    775 
    776     @VisibleForTesting
    777     public static TableEntry createTableEntryForTest(
    778             long channelId,
    779             Program program,
    780             ScheduledRecording scheduledRecording,
    781             long entryStartUtcMillis,
    782             long entryEndUtcMillis,
    783             boolean isBlocked) {
    784         return new TableEntry(
    785                 channelId,
    786                 program,
    787                 scheduledRecording,
    788                 entryStartUtcMillis,
    789                 entryEndUtcMillis,
    790                 isBlocked);
    791     }
    792 
    793     interface Listener {
    794         void onGenresUpdated();
    795 
    796         void onChannelsUpdated();
    797 
    798         void onTimeRangeUpdated();
    799     }
    800 
    801     interface TableEntriesUpdatedListener {
    802         void onTableEntriesUpdated();
    803     }
    804 
    805     interface TableEntryChangedListener {
    806         void onTableEntryChanged(TableEntry entry);
    807     }
    808 
    809     static class ListenerAdapter implements Listener {
    810         @Override
    811         public void onGenresUpdated() {}
    812 
    813         @Override
    814         public void onChannelsUpdated() {}
    815 
    816         @Override
    817         public void onTimeRangeUpdated() {}
    818     }
    819 }
    820