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