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