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
421      * placeholder (e.g., whose channelId is INVALID_ID), when it corresponds to a gap between
422      * programs.
423      */
getTableEntry(long channelId, int index)424     TableEntry getTableEntry(long channelId, int index) {
425         mProgramDataManager.prefetchChannel(channelId, index);
426         return mChannelIdEntriesMap.get(channelId).get(index);
427     }
428 
429     /** Returns list genre ID's which has a channel. */
getFilteredGenreIds()430     List<Integer> getFilteredGenreIds() {
431         return mFilteredGenreIds;
432     }
433 
getSelectedGenreId()434     int getSelectedGenreId() {
435         return mSelectedGenreId;
436     }
437 
438     // Note that This can be happens only if program guide isn't shown
439     // because an user has to select channels as browsable through UI.
updateChannels(boolean clearPreviousTableEntries)440     private void updateChannels(boolean clearPreviousTableEntries) {
441         if (DEBUG) Log.d(TAG, "updateChannels");
442         mChannels = mChannelDataManager.getBrowsableChannelList();
443         mSelectedGenreId = GenreItems.ID_ALL_CHANNELS;
444         mFilteredChannels = mChannels;
445         updateTableEntriesWithoutNotification(clearPreviousTableEntries);
446         // Channel update notification should be called after updating table entries, so that
447         // the listener can get the entries.
448         notifyChannelsUpdated();
449         notifyTableEntriesUpdated();
450         buildGenreFilters();
451     }
452 
453     /** Sets the channel list for testing */
setChannels(List<Channel> channels)454     void setChannels(List<Channel> channels) {
455         mChannels = new ArrayList<>(channels);
456         mSelectedGenreId = GenreItems.ID_ALL_CHANNELS;
457         mFilteredChannels = mChannels;
458         buildGenreFilters();
459     }
460 
updateTableEntries(boolean clear)461     private void updateTableEntries(boolean clear) {
462         updateTableEntriesWithoutNotification(clear);
463         notifyTableEntriesUpdated();
464         buildGenreFilters();
465     }
466 
467     /** Updates the table entries without notifying the change. */
updateTableEntriesWithoutNotification(boolean clear)468     private void updateTableEntriesWithoutNotification(boolean clear) {
469         if (clear) {
470             mChannelIdEntriesMap.clear();
471         }
472         boolean parentalControlsEnabled =
473                 mTvInputManagerHelper.getParentalControlSettings().isParentalControlsEnabled();
474         for (Channel channel : mChannels) {
475             long channelId = channel.getId();
476             // Inline the updating of the mChannelIdEntriesMap here so we can only call
477             // getParentalControlSettings once.
478             List<TableEntry> entries = createProgramEntries(channelId, parentalControlsEnabled);
479             mChannelIdEntriesMap.put(channelId, entries);
480 
481             int size = entries.size();
482             if (DEBUG) {
483                 Log.d(
484                         TAG,
485                         "Programs are loaded for channel "
486                                 + channel.getId()
487                                 + ", loaded size = "
488                                 + size);
489             }
490             if (size == 0) {
491                 continue;
492             }
493             TableEntry lastEntry = entries.get(size - 1);
494             if (mEndUtcMillis < lastEntry.entryEndUtcMillis
495                     && lastEntry.entryEndUtcMillis != Long.MAX_VALUE) {
496                 mEndUtcMillis = lastEntry.entryEndUtcMillis;
497             }
498         }
499         if (mEndUtcMillis > mStartUtcMillis) {
500             for (Channel channel : mChannels) {
501                 long channelId = channel.getId();
502                 List<TableEntry> entries = mChannelIdEntriesMap.get(channelId);
503                 if (entries.isEmpty()) {
504                     entries.add(new TableEntry(channelId, mStartUtcMillis, mEndUtcMillis));
505                 } else {
506                     TableEntry lastEntry = entries.get(entries.size() - 1);
507                     if (mEndUtcMillis > lastEntry.entryEndUtcMillis) {
508                         entries.add(
509                                 new TableEntry(
510                                         channelId, lastEntry.entryEndUtcMillis, mEndUtcMillis));
511                     } else if (lastEntry.entryEndUtcMillis == Long.MAX_VALUE) {
512                         entries.remove(entries.size() - 1);
513                         entries.add(
514                                 new TableEntry(
515                                         lastEntry.channelId,
516                                         lastEntry.program,
517                                         lastEntry.scheduledRecording,
518                                         lastEntry.entryStartUtcMillis,
519                                         mEndUtcMillis,
520                                         lastEntry.mIsBlocked));
521                     }
522                 }
523             }
524         }
525     }
526 
527     /**
528      * Build genre filters based on the current programs. This categories channels by its current
529      * program's canonical genres and subsequent @{link resetChannelListWithGenre(int)} calls will
530      * reset channel list with built channel list. This is expected to be called whenever program
531      * guide is shown.
532      */
buildGenreFilters()533     private void buildGenreFilters() {
534         if (DEBUG) Log.d(TAG, "buildGenreFilters");
535 
536         mGenreChannelList.clear();
537         for (int i = 0; i < GenreItems.getGenreCount(); i++) {
538             mGenreChannelList.add(new ArrayList<>());
539         }
540         for (Channel channel : mChannels) {
541             Program currentProgram = mProgramDataManager.getCurrentProgram(channel.getId());
542             if (currentProgram != null && currentProgram.getCanonicalGenres() != null) {
543                 for (String genre : currentProgram.getCanonicalGenres()) {
544                     mGenreChannelList.get(GenreItems.getId(genre)).add(channel);
545                 }
546             }
547         }
548         mGenreChannelList.set(GenreItems.ID_ALL_CHANNELS, mChannels);
549         mFilteredGenreIds.clear();
550         mFilteredGenreIds.add(0);
551         for (int i = 1; i < GenreItems.getGenreCount(); i++) {
552             if (mGenreChannelList.get(i).size() > 0) {
553                 mFilteredGenreIds.add(i);
554             }
555         }
556         mSelectedGenreId = GenreItems.ID_ALL_CHANNELS;
557         mFilteredChannels = mChannels;
558         notifyGenresUpdated();
559     }
560 
561     @Nullable
getTableEntry(ScheduledRecording scheduledRecording)562     private TableEntry getTableEntry(ScheduledRecording scheduledRecording) {
563         return getTableEntry(scheduledRecording.getChannelId(), scheduledRecording.getProgramId());
564     }
565 
566     @Nullable
getTableEntry(long channelId, long entryId)567     private TableEntry getTableEntry(long channelId, long entryId) {
568         if (mChannelIdEntriesMap.isEmpty()) {
569             return null;
570         }
571         List<TableEntry> entries = mChannelIdEntriesMap.get(channelId);
572         if (entries != null) {
573             for (TableEntry entry : entries) {
574                 if (entry.getId() == entryId) {
575                     return entry;
576                 }
577             }
578         }
579         return null;
580     }
581 
updateEntry(TableEntry old, TableEntry newEntry)582     private void updateEntry(TableEntry old, TableEntry newEntry) {
583         List<TableEntry> entries = mChannelIdEntriesMap.get(old.channelId);
584         int index = entries.indexOf(old);
585         entries.set(index, newEntry);
586         notifyTableEntryUpdated(newEntry);
587     }
588 
setTimeRange(long fromUtcMillis, long toUtcMillis)589     private void setTimeRange(long fromUtcMillis, long toUtcMillis) {
590         if (DEBUG) {
591             Log.d(
592                     TAG,
593                     "setTimeRange. {FromTime="
594                             + Utils.toTimeString(fromUtcMillis)
595                             + ", ToTime="
596                             + Utils.toTimeString(toUtcMillis)
597                             + "}");
598         }
599         if (mFromUtcMillis != fromUtcMillis || mToUtcMillis != toUtcMillis) {
600             mFromUtcMillis = fromUtcMillis;
601             mToUtcMillis = toUtcMillis;
602             notifyTimeRangeUpdated();
603         }
604     }
605 
createProgramEntries(long channelId, boolean parentalControlsEnabled)606     private List<TableEntry> createProgramEntries(long channelId, boolean parentalControlsEnabled) {
607         List<TableEntry> entries = new ArrayList<>();
608         boolean channelLocked =
609                 parentalControlsEnabled && mChannelDataManager.getChannel(channelId).isLocked();
610         if (channelLocked) {
611             entries.add(new TableEntry(channelId, mStartUtcMillis, Long.MAX_VALUE, true));
612         } else {
613             long lastProgramEndTime = mStartUtcMillis;
614             List<Program> programs = mProgramDataManager.getPrograms(channelId, mStartUtcMillis);
615             for (Program program : programs) {
616                 if (program.getChannelId() == INVALID_ID) {
617                     // Placeholder program.
618                     continue;
619                 }
620                 long programStartTime = Math.max(program.getStartTimeUtcMillis(), mStartUtcMillis);
621                 long programEndTime = program.getEndTimeUtcMillis();
622                 if (programStartTime > lastProgramEndTime) {
623                     // Gap since the last program.
624                     entries.add(new TableEntry(channelId, lastProgramEndTime, programStartTime));
625                     lastProgramEndTime = programStartTime;
626                 }
627                 if (programEndTime > lastProgramEndTime) {
628                     ScheduledRecording scheduledRecording =
629                             mDvrDataManager == null
630                                     ? null
631                                     : mDvrDataManager.getScheduledRecordingForProgramId(
632                                             program.getId());
633                     entries.add(
634                             new TableEntry(
635                                     channelId,
636                                     program,
637                                     scheduledRecording,
638                                     lastProgramEndTime,
639                                     programEndTime,
640                                     false));
641                     lastProgramEndTime = programEndTime;
642                 }
643             }
644         }
645 
646         if (entries.size() > 1) {
647             TableEntry secondEntry = entries.get(1);
648             if (secondEntry.entryStartUtcMillis < mStartUtcMillis + FIRST_ENTRY_MIN_DURATION) {
649                 // If the first entry's width doesn't have enough width, it is not good to show
650                 // the first entry from UI perspective. So we clip it out.
651                 entries.remove(0);
652                 entries.set(
653                         0,
654                         new TableEntry(
655                                 secondEntry.channelId,
656                                 secondEntry.program,
657                                 secondEntry.scheduledRecording,
658                                 mStartUtcMillis,
659                                 secondEntry.entryEndUtcMillis,
660                                 secondEntry.mIsBlocked));
661             }
662         }
663         return entries;
664     }
665 
notifyGenresUpdated()666     private void notifyGenresUpdated() {
667         for (Listener listener : mListeners) {
668             listener.onGenresUpdated();
669         }
670     }
671 
notifyChannelsUpdated()672     private void notifyChannelsUpdated() {
673         for (Listener listener : mListeners) {
674             listener.onChannelsUpdated();
675         }
676     }
677 
notifyTimeRangeUpdated()678     private void notifyTimeRangeUpdated() {
679         for (Listener listener : mListeners) {
680             listener.onTimeRangeUpdated();
681         }
682     }
683 
notifyTableEntriesUpdated()684     private void notifyTableEntriesUpdated() {
685         for (TableEntriesUpdatedListener listener : mTableEntriesUpdatedListeners) {
686             listener.onTableEntriesUpdated();
687         }
688     }
689 
notifyTableEntryUpdated(TableEntry entry)690     private void notifyTableEntryUpdated(TableEntry entry) {
691         for (TableEntryChangedListener listener : mTableEntryChangedListeners) {
692             listener.onTableEntryChanged(entry);
693         }
694     }
695 
696     /**
697      * Entry for program guide table. An "entry" can be either an actual program or a gap between
698      * programs. This is needed for {@link ProgramListAdapter} because {@link
699      * androidx.leanback.widget.HorizontalGridView} ignores margins between items.
700      */
701     static class TableEntry {
702         /** Channel ID which this entry is included. */
703         final long channelId;
704 
705         /** Program corresponding to the entry. {@code null} means that this entry is a gap. */
706         final Program program;
707 
708         final ScheduledRecording scheduledRecording;
709 
710         /** Start time of entry in UTC milliseconds. */
711         final long entryStartUtcMillis;
712 
713         /** End time of entry in UTC milliseconds */
714         final long entryEndUtcMillis;
715 
716         private final boolean mIsBlocked;
717 
TableEntry(long channelId, long startUtcMillis, long endUtcMillis)718         private TableEntry(long channelId, long startUtcMillis, long endUtcMillis) {
719             this(channelId, null, startUtcMillis, endUtcMillis, false);
720         }
721 
TableEntry( long channelId, long startUtcMillis, long endUtcMillis, boolean blocked)722         private TableEntry(
723                 long channelId, long startUtcMillis, long endUtcMillis, boolean blocked) {
724             this(channelId, null, null, startUtcMillis, endUtcMillis, blocked);
725         }
726 
TableEntry( long channelId, ProgramImpl program, long entryStartUtcMillis, long entryEndUtcMillis, boolean isBlocked)727         private TableEntry(
728                 long channelId,
729                 ProgramImpl program,
730                 long entryStartUtcMillis,
731                 long entryEndUtcMillis,
732                 boolean isBlocked) {
733             this(channelId, program, null, entryStartUtcMillis, entryEndUtcMillis, isBlocked);
734         }
735 
TableEntry( long channelId, Program program, ScheduledRecording scheduledRecording, long entryStartUtcMillis, long entryEndUtcMillis, boolean isBlocked)736         private TableEntry(
737                 long channelId,
738                 Program program,
739                 ScheduledRecording scheduledRecording,
740                 long entryStartUtcMillis,
741                 long entryEndUtcMillis,
742                 boolean isBlocked) {
743             this.channelId = channelId;
744             this.program = program;
745             this.scheduledRecording = scheduledRecording;
746             this.entryStartUtcMillis = entryStartUtcMillis;
747             this.entryEndUtcMillis = entryEndUtcMillis;
748             mIsBlocked = isBlocked;
749         }
750 
751         /** A stable id useful for {@link androidx.recyclerview.widget.RecyclerView.Adapter}. */
getId()752         long getId() {
753             // using a negative entryEndUtcMillis keeps it from conflicting with program Id
754             return program != null ? program.getId() : -entryEndUtcMillis;
755         }
756 
757         /** Returns true if this is a gap. */
isGap()758         boolean isGap() {
759             return !Program.isProgramValid(program);
760         }
761 
762         /** Returns true if this channel is blocked. */
isBlocked()763         boolean isBlocked() {
764             return mIsBlocked;
765         }
766 
767         /** Returns true if this program is on the air. */
isCurrentProgram()768         boolean isCurrentProgram() {
769             long current = System.currentTimeMillis();
770             return entryStartUtcMillis <= current && entryEndUtcMillis > current;
771         }
772 
773         /** Returns if this program has the genre. */
hasGenre(int genreId)774         boolean hasGenre(int genreId) {
775             return !isGap() && program.hasGenre(genreId);
776         }
777 
778         /** Returns the width of table entry, in pixels. */
getWidth()779         int getWidth() {
780             return GuideUtils.convertMillisToPixel(entryStartUtcMillis, entryEndUtcMillis);
781         }
782 
783         @Override
toString()784         public String toString() {
785             return "TableEntry{"
786                     + "hashCode="
787                     + hashCode()
788                     + ", channelId="
789                     + channelId
790                     + ", program="
791                     + program
792                     + ", startTime="
793                     + Utils.toTimeString(entryStartUtcMillis)
794                     + ", endTimeTime="
795                     + Utils.toTimeString(entryEndUtcMillis)
796                     + "}";
797         }
798     }
799 
800     @VisibleForTesting
createTableEntryForTest( long channelId, Program program, ScheduledRecording scheduledRecording, long entryStartUtcMillis, long entryEndUtcMillis, boolean isBlocked)801     public static TableEntry createTableEntryForTest(
802             long channelId,
803             Program program,
804             ScheduledRecording scheduledRecording,
805             long entryStartUtcMillis,
806             long entryEndUtcMillis,
807             boolean isBlocked) {
808         return new TableEntry(
809                 channelId,
810                 program,
811                 scheduledRecording,
812                 entryStartUtcMillis,
813                 entryEndUtcMillis,
814                 isBlocked);
815     }
816 
817     interface Listener {
onGenresUpdated()818         void onGenresUpdated();
819 
onChannelsUpdated()820         void onChannelsUpdated();
821 
onTimeRangeUpdated()822         void onTimeRangeUpdated();
823     }
824 
825     interface TableEntriesUpdatedListener {
onTableEntriesUpdated()826         void onTableEntriesUpdated();
827     }
828 
829     interface TableEntryChangedListener {
onTableEntryChanged(TableEntry entry)830         void onTableEntryChanged(TableEntry entry);
831     }
832 
833     static class ListenerAdapter implements Listener {
834         @Override
onGenresUpdated()835         public void onGenresUpdated() {}
836 
837         @Override
onChannelsUpdated()838         public void onChannelsUpdated() {}
839 
840         @Override
onTimeRangeUpdated()841         public void onTimeRangeUpdated() {}
842     }
843 }
844