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.dvr;
18 
19 import android.annotation.TargetApi;
20 import android.content.Context;
21 import android.media.tv.TvInputInfo;
22 import android.os.Build;
23 import android.support.annotation.MainThread;
24 import android.support.annotation.NonNull;
25 import android.support.annotation.VisibleForTesting;
26 import android.util.ArraySet;
27 import android.util.LongSparseArray;
28 import android.util.Range;
29 
30 import com.android.tv.ApplicationSingletons;
31 import com.android.tv.TvApplication;
32 import com.android.tv.common.SoftPreconditions;
33 import com.android.tv.data.Channel;
34 import com.android.tv.data.ChannelDataManager;
35 import com.android.tv.data.Program;
36 import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener;
37 import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
38 import com.android.tv.util.CompositeComparator;
39 import com.android.tv.util.Utils;
40 
41 import java.util.ArrayList;
42 import java.util.Collections;
43 import java.util.Comparator;
44 import java.util.HashMap;
45 import java.util.Iterator;
46 import java.util.List;
47 import java.util.Map;
48 import java.util.Set;
49 import java.util.concurrent.CopyOnWriteArraySet;
50 
51 /**
52  * A class to manage the schedules.
53  */
54 @TargetApi(Build.VERSION_CODES.N)
55 @MainThread
56 public class DvrScheduleManager {
57     private static final String TAG = "DvrScheduleManager";
58 
59     /**
60      * The default priority of scheduled recording.
61      */
62     public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1;
63     /**
64      * The default priority of series recording.
65      */
66     public static final long DEFAULT_SERIES_PRIORITY = DEFAULT_PRIORITY >> 1;
67     // The new priority will have the offset from the existing one.
68     private static final long PRIORITY_OFFSET = 1024;
69 
70     private static final Comparator<ScheduledRecording> RESULT_COMPARATOR =
71             new CompositeComparator<>(
72                     ScheduledRecording.PRIORITY_COMPARATOR.reversed(),
73                     ScheduledRecording.START_TIME_COMPARATOR,
74                     ScheduledRecording.ID_COMPARATOR.reversed());
75 
76     // The candidate comparator should be the consistent with
77     // InputTaskScheduler#CANDIDATE_COMPARATOR.
78     private static final Comparator<ScheduledRecording> CANDIDATE_COMPARATOR =
79             new CompositeComparator<>(
80                     ScheduledRecording.PRIORITY_COMPARATOR,
81                     ScheduledRecording.END_TIME_COMPARATOR,
82                     ScheduledRecording.ID_COMPARATOR);
83 
84     private final Context mContext;
85     private final DvrDataManagerImpl mDataManager;
86     private final ChannelDataManager mChannelDataManager;
87 
88     private final Map<String, List<ScheduledRecording>> mInputScheduleMap = new HashMap<>();
89     // The inner map is a hash map from scheduled recording to its conflicting status, i.e.,
90     // the boolean value true denotes the schedule is just partially conflicting, which means
91     // although there's conflictit, it might still be recorded partially.
92     private final Map<String, Map<ScheduledRecording, Boolean>> mInputConflictInfoMap =
93             new HashMap<>();
94 
95     private boolean mInitialized;
96 
97     private final Set<OnInitializeListener> mOnInitializeListeners = new CopyOnWriteArraySet<>();
98     private final Set<ScheduledRecordingListener> mScheduledRecordingListeners = new ArraySet<>();
99     private final Set<OnConflictStateChangeListener> mOnConflictStateChangeListeners =
100             new ArraySet<>();
101 
DvrScheduleManager(Context context)102     public DvrScheduleManager(Context context) {
103         mContext = context;
104         ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
105         mDataManager = (DvrDataManagerImpl) appSingletons.getDvrDataManager();
106         mChannelDataManager = appSingletons.getChannelDataManager();
107         if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) {
108             buildData();
109         } else {
110             mDataManager.addDvrScheduleLoadFinishedListener(
111                     new OnDvrScheduleLoadFinishedListener() {
112                         @Override
113                         public void onDvrScheduleLoadFinished() {
114                             mDataManager.removeDvrScheduleLoadFinishedListener(this);
115                             if (mChannelDataManager.isDbLoadFinished() && !mInitialized) {
116                                 buildData();
117                             }
118                         }
119                     });
120         }
121         ScheduledRecordingListener scheduledRecordingListener = new ScheduledRecordingListener() {
122             @Override
123             public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
124                 if (!mInitialized) {
125                     return;
126                 }
127                 for (ScheduledRecording schedule : scheduledRecordings) {
128                     if (!schedule.isNotStarted() && !schedule.isInProgress()) {
129                         continue;
130                     }
131                     TvInputInfo input = Utils
132                             .getTvInputInfoForInputId(mContext, schedule.getInputId());
133                     if (!SoftPreconditions.checkArgument(input != null, TAG,
134                             "Input was removed for : " + schedule)) {
135                         // Input removed.
136                         mInputScheduleMap.remove(schedule.getInputId());
137                         mInputConflictInfoMap.remove(schedule.getInputId());
138                         continue;
139                     }
140                     String inputId = input.getId();
141                     List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
142                     if (schedules == null) {
143                         schedules = new ArrayList<>();
144                         mInputScheduleMap.put(inputId, schedules);
145                     }
146                     schedules.add(schedule);
147                 }
148                 onSchedulesChanged();
149                 notifyScheduledRecordingAdded(scheduledRecordings);
150             }
151 
152             @Override
153             public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
154                 if (!mInitialized) {
155                     return;
156                 }
157                 for (ScheduledRecording schedule : scheduledRecordings) {
158                     TvInputInfo input = Utils
159                             .getTvInputInfoForInputId(mContext, schedule.getInputId());
160                     if (input == null) {
161                         // Input removed.
162                         mInputScheduleMap.remove(schedule.getInputId());
163                         mInputConflictInfoMap.remove(schedule.getInputId());
164                         continue;
165                     }
166                     String inputId = input.getId();
167                     List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
168                     if (schedules != null) {
169                         schedules.remove(schedule);
170                         if (schedules.isEmpty()) {
171                             mInputScheduleMap.remove(inputId);
172                         }
173                     }
174                     Map<ScheduledRecording, Boolean> conflictInfo =
175                             mInputConflictInfoMap.get(inputId);
176                     if (conflictInfo != null) {
177                         conflictInfo.remove(schedule);
178                         if (conflictInfo.isEmpty()) {
179                             mInputConflictInfoMap.remove(inputId);
180                         }
181                     }
182                 }
183                 onSchedulesChanged();
184                 notifyScheduledRecordingRemoved(scheduledRecordings);
185             }
186 
187             @Override
188             public void onScheduledRecordingStatusChanged(
189                     ScheduledRecording... scheduledRecordings) {
190                 if (!mInitialized) {
191                     return;
192                 }
193                 for (ScheduledRecording schedule : scheduledRecordings) {
194                     TvInputInfo input = Utils
195                             .getTvInputInfoForInputId(mContext, schedule.getInputId());
196                     if (!SoftPreconditions.checkArgument(input != null, TAG,
197                             "Input was removed for : " + schedule)) {
198                         // Input removed.
199                         mInputScheduleMap.remove(schedule.getInputId());
200                         mInputConflictInfoMap.remove(schedule.getInputId());
201                         continue;
202                     }
203                     String inputId = input.getId();
204                     List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
205                     if (schedules == null) {
206                         schedules = new ArrayList<>();
207                         mInputScheduleMap.put(inputId, schedules);
208                     }
209                     // Compare ID because ScheduledRecording.equals() doesn't work if the state
210                     // is changed.
211                     for (Iterator<ScheduledRecording> i = schedules.iterator(); i.hasNext(); ) {
212                         if (i.next().getId() == schedule.getId()) {
213                             i.remove();
214                             break;
215                         }
216                     }
217                     if (schedule.isNotStarted() || schedule.isInProgress()) {
218                         schedules.add(schedule);
219                     }
220                     if (schedules.isEmpty()) {
221                         mInputScheduleMap.remove(inputId);
222                     }
223                     // Update conflict list as well
224                     Map<ScheduledRecording, Boolean> conflictInfo =
225                             mInputConflictInfoMap.get(inputId);
226                     if (conflictInfo != null) {
227                         // Compare ID because ScheduledRecording.equals() doesn't work if the state
228                         // is changed.
229                         ScheduledRecording oldSchedule = null;
230                         for (ScheduledRecording s : conflictInfo.keySet()) {
231                             if (s.getId() == schedule.getId()) {
232                                 oldSchedule = s;
233                                 break;
234                             }
235                         }
236                         if (oldSchedule != null) {
237                             conflictInfo.put(schedule, conflictInfo.get(oldSchedule));
238                             conflictInfo.remove(oldSchedule);
239                         }
240                     }
241                 }
242                 onSchedulesChanged();
243                 notifyScheduledRecordingStatusChanged(scheduledRecordings);
244             }
245         };
246         mDataManager.addScheduledRecordingListener(scheduledRecordingListener);
247         ChannelDataManager.Listener channelDataManagerListener = new ChannelDataManager.Listener() {
248             @Override
249             public void onLoadFinished() {
250                 if (mDataManager.isDvrScheduleLoadFinished() && !mInitialized) {
251                     buildData();
252                 }
253             }
254 
255             @Override
256             public void onChannelListUpdated() {
257                 if (mDataManager.isDvrScheduleLoadFinished()) {
258                     buildData();
259                 }
260             }
261 
262             @Override
263             public void onChannelBrowsableChanged() {
264             }
265         };
266         mChannelDataManager.addListener(channelDataManagerListener);
267     }
268 
269     /**
270      * Returns the started recordings for the given input.
271      */
getStartedRecordings(String inputId)272     private List<ScheduledRecording> getStartedRecordings(String inputId) {
273         if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) {
274             return Collections.emptyList();
275         }
276         List<ScheduledRecording> result = new ArrayList<>();
277         List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
278         if (schedules != null) {
279             for (ScheduledRecording schedule : schedules) {
280                 if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
281                     result.add(schedule);
282                 }
283             }
284         }
285         return result;
286     }
287 
buildData()288     private void buildData() {
289         mInputScheduleMap.clear();
290         for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) {
291             if (!schedule.isNotStarted() && !schedule.isInProgress()) {
292                 continue;
293             }
294             Channel channel = mChannelDataManager.getChannel(schedule.getChannelId());
295             if (channel != null) {
296                 String inputId = channel.getInputId();
297                 // Do not check whether the input is valid or not. The input might be temporarily
298                 // invalid.
299                 List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
300                 if (schedules == null) {
301                     schedules = new ArrayList<>();
302                     mInputScheduleMap.put(inputId, schedules);
303                 }
304                 schedules.add(schedule);
305             }
306         }
307         if (!mInitialized) {
308             mInitialized = true;
309             notifyInitialize();
310         }
311         onSchedulesChanged();
312     }
313 
onSchedulesChanged()314     private void onSchedulesChanged() {
315         // TODO: notify conflict state change when some conflicting recording becomes partially
316         //       conflicting, vice versa.
317         List<ScheduledRecording> addedConflicts = new ArrayList<>();
318         List<ScheduledRecording> removedConflicts = new ArrayList<>();
319         for (String inputId : mInputScheduleMap.keySet()) {
320             Map<ScheduledRecording, Boolean> oldConflictsInfo = mInputConflictInfoMap.get(inputId);
321             Map<Long, ScheduledRecording> oldConflictMap = new HashMap<>();
322             if (oldConflictsInfo != null) {
323                 for (ScheduledRecording r : oldConflictsInfo.keySet()) {
324                     oldConflictMap.put(r.getId(), r);
325                 }
326             }
327             Map<ScheduledRecording, Boolean> conflictInfo = getConflictingSchedulesInfo(inputId);
328             if (conflictInfo.isEmpty()) {
329                 mInputConflictInfoMap.remove(inputId);
330             } else {
331                 mInputConflictInfoMap.put(inputId, conflictInfo);
332                 List<ScheduledRecording> conflicts = new ArrayList<>(conflictInfo.keySet());
333                 for (ScheduledRecording r : conflicts) {
334                     if (oldConflictMap.remove(r.getId()) == null) {
335                         addedConflicts.add(r);
336                     }
337                 }
338             }
339             removedConflicts.addAll(oldConflictMap.values());
340         }
341         if (!removedConflicts.isEmpty()) {
342             notifyConflictStateChange(false, ScheduledRecording.toArray(removedConflicts));
343         }
344         if (!addedConflicts.isEmpty()) {
345             notifyConflictStateChange(true, ScheduledRecording.toArray(addedConflicts));
346         }
347     }
348 
349     /**
350      * Returns {@code true} if this class has been initialized.
351      */
isInitialized()352     public boolean isInitialized() {
353         return mInitialized;
354     }
355 
356     /**
357      * Adds a {@link ScheduledRecordingListener}.
358      */
addScheduledRecordingListener(ScheduledRecordingListener listener)359     public final void addScheduledRecordingListener(ScheduledRecordingListener listener) {
360         mScheduledRecordingListeners.add(listener);
361     }
362 
363     /**
364      * Removes a {@link ScheduledRecordingListener}.
365      */
removeScheduledRecordingListener(ScheduledRecordingListener listener)366     public final void removeScheduledRecordingListener(ScheduledRecordingListener listener) {
367         mScheduledRecordingListeners.remove(listener);
368     }
369 
370     /**
371      * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded} for each listener.
372      */
notifyScheduledRecordingAdded(ScheduledRecording... scheduledRecordings)373     private void notifyScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
374         for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
375             l.onScheduledRecordingAdded(scheduledRecordings);
376         }
377     }
378 
379     /**
380      * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved} for each listener.
381      */
notifyScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings)382     private void notifyScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
383         for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
384             l.onScheduledRecordingRemoved(scheduledRecordings);
385         }
386     }
387 
388     /**
389      * Calls {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged} for each listener.
390      */
notifyScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings)391     private void notifyScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
392         for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
393             l.onScheduledRecordingStatusChanged(scheduledRecordings);
394         }
395     }
396 
397     /**
398      * Adds a {@link OnInitializeListener}.
399      */
addOnInitializeListener(OnInitializeListener listener)400     public final void addOnInitializeListener(OnInitializeListener listener) {
401         mOnInitializeListeners.add(listener);
402     }
403 
404     /**
405      * Removes a {@link OnInitializeListener}.
406      */
removeOnInitializeListener(OnInitializeListener listener)407     public final void removeOnInitializeListener(OnInitializeListener listener) {
408         mOnInitializeListeners.remove(listener);
409     }
410 
411     /**
412      * Calls {@link OnInitializeListener#onInitialize} for each listener.
413      */
notifyInitialize()414     private void notifyInitialize() {
415         for (OnInitializeListener l : mOnInitializeListeners) {
416             l.onInitialize();
417         }
418     }
419 
420     /**
421      * Adds a {@link OnConflictStateChangeListener}.
422      */
addOnConflictStateChangeListener(OnConflictStateChangeListener listener)423     public final void addOnConflictStateChangeListener(OnConflictStateChangeListener listener) {
424         mOnConflictStateChangeListeners.add(listener);
425     }
426 
427     /**
428      * Removes a {@link OnConflictStateChangeListener}.
429      */
removeOnConflictStateChangeListener(OnConflictStateChangeListener listener)430     public final void removeOnConflictStateChangeListener(OnConflictStateChangeListener listener) {
431         mOnConflictStateChangeListeners.remove(listener);
432     }
433 
434     /**
435      * Calls {@link OnConflictStateChangeListener#onConflictStateChange} for each listener.
436      */
notifyConflictStateChange(boolean conflict, ScheduledRecording... scheduledRecordings)437     private void notifyConflictStateChange(boolean conflict,
438             ScheduledRecording... scheduledRecordings) {
439         for (OnConflictStateChangeListener l : mOnConflictStateChangeListeners) {
440             l.onConflictStateChange(conflict, scheduledRecordings);
441         }
442     }
443 
444     /**
445      * Returns the priority for the program if it is recorded.
446      * <p>
447      * The recording will have the higher priority than the existing ones.
448      */
suggestNewPriority()449     public long suggestNewPriority() {
450         if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) {
451             return DEFAULT_PRIORITY;
452         }
453         return suggestHighestPriority();
454     }
455 
suggestHighestPriority()456     private long suggestHighestPriority() {
457         long highestPriority = DEFAULT_PRIORITY - PRIORITY_OFFSET;
458         for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) {
459             if (schedule.getPriority() > highestPriority) {
460                 highestPriority = schedule.getPriority();
461             }
462         }
463         return highestPriority + PRIORITY_OFFSET;
464     }
465 
466     /**
467      * Suggests the higher priority than the schedules which overlap with {@code schedule}.
468      */
suggestHighestPriority(ScheduledRecording schedule)469     public long suggestHighestPriority(ScheduledRecording schedule) {
470         List<ScheduledRecording> schedules = mInputScheduleMap.get(schedule.getInputId());
471         if (schedules == null) {
472             return DEFAULT_PRIORITY;
473         }
474         long highestPriority = Long.MIN_VALUE;
475         for (ScheduledRecording r : schedules) {
476             if (!r.equals(schedule) && r.isOverLapping(schedule)
477                     && r.getPriority() > highestPriority) {
478                 highestPriority = r.getPriority();
479             }
480         }
481         if (highestPriority == Long.MIN_VALUE || highestPriority < schedule.getPriority()) {
482             return schedule.getPriority();
483         }
484         return highestPriority + PRIORITY_OFFSET;
485     }
486 
487     /**
488      * Suggests the higher priority than the schedules which overlap with {@code schedule}.
489      */
suggestHighestPriority(String inputId, Range<Long> peroid, long basePriority)490     public long suggestHighestPriority(String inputId, Range<Long> peroid, long basePriority) {
491         List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
492         if (schedules == null) {
493             return DEFAULT_PRIORITY;
494         }
495         long highestPriority = Long.MIN_VALUE;
496         for (ScheduledRecording r : schedules) {
497             if (r.isOverLapping(peroid) && r.getPriority() > highestPriority) {
498                 highestPriority = r.getPriority();
499             }
500         }
501         if (highestPriority == Long.MIN_VALUE || highestPriority < basePriority) {
502             return basePriority;
503         }
504         return highestPriority + PRIORITY_OFFSET;
505     }
506 
507     /**
508      * Returns the priority for a series recording.
509      * <p>
510      * The recording will have the higher priority than the existing series.
511      */
suggestNewSeriesPriority()512     public long suggestNewSeriesPriority() {
513         if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) {
514             return DEFAULT_SERIES_PRIORITY;
515         }
516         return suggestHighestSeriesPriority();
517     }
518 
519     /**
520      * Returns the priority for a series recording by order of series recording priority.
521      *
522      * Higher order will have higher priority.
523      */
suggestSeriesPriority(int order)524     public static long suggestSeriesPriority(int order) {
525         return DEFAULT_SERIES_PRIORITY + order * PRIORITY_OFFSET;
526     }
527 
suggestHighestSeriesPriority()528     private long suggestHighestSeriesPriority() {
529         long highestPriority = DEFAULT_SERIES_PRIORITY - PRIORITY_OFFSET;
530         for (SeriesRecording schedule : mDataManager.getSeriesRecordings()) {
531             if (schedule.getPriority() > highestPriority) {
532                 highestPriority = schedule.getPriority();
533             }
534         }
535         return highestPriority + PRIORITY_OFFSET;
536     }
537 
538     /**
539      * Returns a sorted list of all scheduled recordings that will not be recorded if
540      * this program is going to be recorded, with their priorities in decending order.
541      * <p>
542      * An empty list means there is no conflicts. If there is conflict, a priority higher than
543      * the first recording in the returned list should be assigned to the new schedule of this
544      * program to guarantee the program would be completely recorded.
545      */
getConflictingSchedules(Program program)546     public List<ScheduledRecording> getConflictingSchedules(Program program) {
547         SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
548         SoftPreconditions.checkState(Program.isValid(program), TAG,
549                 "Program is invalid: " + program);
550         SoftPreconditions.checkState(
551                 program.getStartTimeUtcMillis() < program.getEndTimeUtcMillis(), TAG,
552                 "Program duration is empty: " + program);
553         if (!mInitialized || !Program.isValid(program)
554                 || program.getStartTimeUtcMillis() >= program.getEndTimeUtcMillis()) {
555             return Collections.emptyList();
556         }
557         TvInputInfo input = Utils.getTvInputInfoForProgram(mContext, program);
558         if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
559             return Collections.emptyList();
560         }
561         return getConflictingSchedules(input, Collections.singletonList(
562                 ScheduledRecording.builder(input.getId(), program)
563                         .setPriority(suggestHighestPriority())
564                         .build()));
565     }
566 
567     /**
568      * Returns list of all conflicting scheduled recordings with schedules belonging to {@code
569      * seriesRecording}
570      * recording.
571      * <p>
572      * Any empty list means there is no conflicts.
573      */
getConflictingSchedules(SeriesRecording seriesRecording)574     public List<ScheduledRecording> getConflictingSchedules(SeriesRecording seriesRecording) {
575         SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
576         SoftPreconditions.checkState(seriesRecording != null, TAG, "series recording is null");
577         if (!mInitialized || seriesRecording == null) {
578             return Collections.emptyList();
579         }
580         TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, seriesRecording.getInputId());
581         if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
582             return Collections.emptyList();
583         }
584         List<ScheduledRecording> schedulesForSeries = mDataManager.getScheduledRecordings(
585                 seriesRecording.getId());
586         return getConflictingSchedules(input, schedulesForSeries);
587     }
588 
589     /**
590      * Returns a sorted list of all scheduled recordings that will not be recorded if
591      * this channel is going to be recorded, with their priority in decending order.
592      * <p>
593      * An empty list means there is no conflicts. If there is conflict, a priority higher than
594      * the first recording in the returned list should be assigned to the new schedule of this
595      * channel to guarantee the channel would be completely recorded in the designated time range.
596      */
getConflictingSchedules(long channelId, long startTimeMs, long endTimeMs)597     public List<ScheduledRecording> getConflictingSchedules(long channelId, long startTimeMs,
598             long endTimeMs) {
599         SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
600         SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID");
601         SoftPreconditions.checkState(startTimeMs < endTimeMs, TAG, "Recording duration is empty.");
602         if (!mInitialized || channelId == Channel.INVALID_ID || startTimeMs >= endTimeMs) {
603             return Collections.emptyList();
604         }
605         TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId);
606         if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
607             return Collections.emptyList();
608         }
609         return getConflictingSchedules(input, Collections.singletonList(
610                 ScheduledRecording.builder(input.getId(), channelId, startTimeMs, endTimeMs)
611                         .setPriority(suggestHighestPriority())
612                         .build()));
613     }
614 
615     /**
616      * Returns all the scheduled recordings that conflicts and will not be recorded or clipped for
617      * the given input.
618      */
619     @NonNull
getConflictingSchedulesInfo(String inputId)620     private Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo(String inputId) {
621         SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
622         TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, inputId);
623         SoftPreconditions.checkState(input != null, TAG, "Can't find input for : " + inputId);
624         if (!mInitialized || input == null) {
625             return Collections.emptyMap();
626         }
627         List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId());
628         if (schedules == null || schedules.isEmpty()) {
629             return Collections.emptyMap();
630         }
631         return getConflictingSchedulesInfo(schedules, input.getTunerCount());
632     }
633 
634     /**
635      * Checks if the schedule is conflicting.
636      *
637      * <p>Note that the {@code schedule} should be the existing one. If not, this returns
638      * {@code false}.
639      */
isConflicting(ScheduledRecording schedule)640     public boolean isConflicting(ScheduledRecording schedule) {
641         SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
642         TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId());
643         SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : "
644                 + schedule.getChannelId());
645         if (!mInitialized || input == null) {
646             return false;
647         }
648         Map<ScheduledRecording, Boolean> conflicts = mInputConflictInfoMap.get(input.getId());
649         return conflicts != null && conflicts.containsKey(schedule);
650     }
651 
652     /**
653      * Checks if the schedule is partially conflicting, i.e., part of the scheduled program might be
654      * recorded even if the priority of the schedule is not raised.
655      * <p>
656      * If the given schedule is not conflicting or is totally conflicting, i.e., cannot be recorded
657      * at all, this method returns {@code false} in both cases.
658      */
isPartiallyConflicting(@onNull ScheduledRecording schedule)659     public boolean isPartiallyConflicting(@NonNull ScheduledRecording schedule) {
660         SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
661         TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId());
662         SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : "
663                 + schedule.getChannelId());
664         if (!mInitialized || input == null) {
665             return false;
666         }
667         Map<ScheduledRecording, Boolean> conflicts = mInputConflictInfoMap.get(input.getId());
668         return conflicts != null && conflicts.getOrDefault(schedule, false);
669     }
670 
671     /**
672      * Returns priority ordered list of all scheduled recordings that will not be recorded if
673      * this channel is tuned to.
674      */
getConflictingSchedulesForTune(long channelId)675     public List<ScheduledRecording> getConflictingSchedulesForTune(long channelId) {
676         SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
677         SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID");
678         TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId);
679         SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: "
680                 + channelId);
681         if (!mInitialized || channelId == Channel.INVALID_ID || input == null) {
682             return Collections.emptyList();
683         }
684         return getConflictingSchedulesForTune(input.getId(), channelId, System.currentTimeMillis(),
685                 suggestHighestPriority(), getStartedRecordings(input.getId()),
686                 input.getTunerCount());
687     }
688 
689     @VisibleForTesting
getConflictingSchedulesForTune(String inputId, long channelId, long currentTimeMs, long newPriority, List<ScheduledRecording> startedRecordings, int tunerCount)690     public static List<ScheduledRecording> getConflictingSchedulesForTune(String inputId,
691             long channelId, long currentTimeMs, long newPriority,
692             List<ScheduledRecording> startedRecordings, int tunerCount) {
693         boolean channelFound = false;
694         for (ScheduledRecording schedule : startedRecordings) {
695             if (schedule.getChannelId() == channelId) {
696                 channelFound = true;
697                 break;
698             }
699         }
700         List<ScheduledRecording> schedules;
701         if (!channelFound) {
702             // The current channel is not being recorded.
703             schedules = new ArrayList<>(startedRecordings);
704             schedules.add(ScheduledRecording
705                     .builder(inputId, channelId, currentTimeMs, currentTimeMs + 1)
706                     .setPriority(newPriority)
707                     .build());
708         } else {
709             schedules = startedRecordings;
710         }
711         return getConflictingSchedules(schedules, tunerCount);
712     }
713 
714     /**
715      * Returns priority ordered list of all scheduled recordings that will not be recorded if
716      * the user keeps watching this channel.
717      * <p>
718      * Note that if the user keeps watching the channel, the channel can be recorded.
719      */
getConflictingSchedulesForWatching(long channelId)720     public List<ScheduledRecording> getConflictingSchedulesForWatching(long channelId) {
721         SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
722         SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID");
723         TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId);
724         SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: "
725                 + channelId);
726         if (!mInitialized || channelId == Channel.INVALID_ID || input == null) {
727             return Collections.emptyList();
728         }
729         List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId());
730         if (schedules == null || schedules.isEmpty()) {
731             return Collections.emptyList();
732         }
733         return getConflictingSchedulesForWatching(input.getId(), channelId,
734                 System.currentTimeMillis(), suggestNewPriority(), schedules, input.getTunerCount());
735     }
736 
getConflictingSchedules(TvInputInfo input, List<ScheduledRecording> schedulesToAdd)737     private List<ScheduledRecording> getConflictingSchedules(TvInputInfo input,
738             List<ScheduledRecording> schedulesToAdd) {
739         SoftPreconditions.checkNotNull(input);
740         if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
741             return Collections.emptyList();
742         }
743         List<ScheduledRecording> currentSchedules = mInputScheduleMap.get(input.getId());
744         if (currentSchedules == null || currentSchedules.isEmpty()) {
745             return Collections.emptyList();
746         }
747         return getConflictingSchedules(schedulesToAdd, currentSchedules, input.getTunerCount());
748     }
749 
750     @VisibleForTesting
getConflictingSchedulesForWatching(String inputId, long channelId, long currentTimeMs, long newPriority, @NonNull List<ScheduledRecording> schedules, int tunerCount)751     static List<ScheduledRecording> getConflictingSchedulesForWatching(String inputId,
752             long channelId, long currentTimeMs, long newPriority,
753             @NonNull List<ScheduledRecording> schedules, int tunerCount) {
754         List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules);
755         List<ScheduledRecording> schedulesSameChannel = new ArrayList<>();
756         for (ScheduledRecording schedule : schedules) {
757             if (schedule.getChannelId() == channelId) {
758                 schedulesSameChannel.add(schedule);
759                 schedulesToCheck.remove(schedule);
760             }
761         }
762         // Assume that the user will watch the current channel forever.
763         schedulesToCheck.add(ScheduledRecording
764                 .builder(inputId, channelId, currentTimeMs, Long.MAX_VALUE)
765                 .setPriority(newPriority)
766                 .build());
767         List<ScheduledRecording> result = new ArrayList<>();
768         result.addAll(getConflictingSchedules(schedulesSameChannel, 1));
769         result.addAll(getConflictingSchedules(schedulesToCheck, tunerCount));
770         Collections.sort(result, RESULT_COMPARATOR);
771         return result;
772     }
773 
774     @VisibleForTesting
getConflictingSchedules(List<ScheduledRecording> schedulesToAdd, List<ScheduledRecording> currentSchedules, int tunerCount)775     static List<ScheduledRecording> getConflictingSchedules(List<ScheduledRecording> schedulesToAdd,
776             List<ScheduledRecording> currentSchedules, int tunerCount) {
777         List<ScheduledRecording> schedulesToCheck = new ArrayList<>(currentSchedules);
778         // When the duplicate schedule is to be added, remove the current duplicate recording.
779         for (Iterator<ScheduledRecording> iter = schedulesToCheck.iterator(); iter.hasNext(); ) {
780             ScheduledRecording schedule = iter.next();
781             for (ScheduledRecording toAdd : schedulesToAdd) {
782                 if (schedule.getType() == ScheduledRecording.TYPE_PROGRAM) {
783                     if (toAdd.getProgramId() == schedule.getProgramId()) {
784                         iter.remove();
785                         break;
786                     }
787                 } else {
788                     if (toAdd.getChannelId() == schedule.getChannelId()
789                             && toAdd.getStartTimeMs() == schedule.getStartTimeMs()
790                             && toAdd.getEndTimeMs() == schedule.getEndTimeMs()) {
791                         iter.remove();
792                         break;
793                     }
794                 }
795             }
796         }
797         schedulesToCheck.addAll(schedulesToAdd);
798         List<Range<Long>> ranges = new ArrayList<>();
799         for (ScheduledRecording schedule : schedulesToAdd) {
800             ranges.add(new Range<>(schedule.getStartTimeMs(), schedule.getEndTimeMs()));
801         }
802         return getConflictingSchedules(schedulesToCheck, tunerCount, ranges);
803     }
804 
805     /**
806      * Returns all conflicting scheduled recordings for the given schedules and count of tuner.
807      */
getConflictingSchedules( List<ScheduledRecording> schedules, int tunerCount)808     public static List<ScheduledRecording> getConflictingSchedules(
809             List<ScheduledRecording> schedules, int tunerCount) {
810         return getConflictingSchedules(schedules, tunerCount, null);
811     }
812 
813     @VisibleForTesting
getConflictingSchedules( List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods)814     static List<ScheduledRecording> getConflictingSchedules(
815             List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) {
816         List<ScheduledRecording> result = new ArrayList<>(
817                 getConflictingSchedulesInfo(schedules, tunerCount, periods).keySet());
818         Collections.sort(result, RESULT_COMPARATOR);
819         return result;
820     }
821 
822     @VisibleForTesting
getConflictingSchedulesInfo( List<ScheduledRecording> schedules, int tunerCount)823     static Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo(
824             List<ScheduledRecording> schedules, int tunerCount) {
825         return getConflictingSchedulesInfo(schedules, tunerCount, null);
826     }
827 
828     /**
829      * This is the core method to calculate all the conflicting schedules (in given periods).
830      * <p>
831      * Note that this method will ignore duplicated schedules with a same hash code. (Please refer
832      * to {@link ScheduledRecording#hashCode}.)
833      *
834      * @return A {@link HashMap} from {@link ScheduledRecording} to {@link Boolean}. The boolean
835      *         value denotes if the scheduled recording is partially conflicting, i.e., is possible
836      *         to be partially recorded under the given schedules and tuner count {@code true},
837      *         or not {@code false}.
838      */
getConflictingSchedulesInfo( List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods)839     private static Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo(
840             List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) {
841         List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules);
842         // Sort by the same order as that in InputTaskScheduler.
843         Collections.sort(schedulesToCheck, InputTaskScheduler.getRecordingOrderComparator());
844         List<ScheduledRecording> recordings = new ArrayList<>();
845         Map<ScheduledRecording, Boolean> conflicts = new HashMap<>();
846         Map<ScheduledRecording, ScheduledRecording> modified2OriginalSchedules = new HashMap<>();
847         // Simulate InputTaskScheduler.
848         while (!schedulesToCheck.isEmpty()) {
849             ScheduledRecording schedule = schedulesToCheck.remove(0);
850             removeFinishedRecordings(recordings, schedule.getStartTimeMs());
851             if (recordings.size() < tunerCount) {
852                 recordings.add(schedule);
853                 if (modified2OriginalSchedules.containsKey(schedule)) {
854                     // Schedule has been modified, which means it's already conflicted.
855                     // Modify its state to partially conflicted.
856                     conflicts.put(modified2OriginalSchedules.get(schedule), true);
857                 }
858             } else {
859                 ScheduledRecording candidate = findReplaceableRecording(recordings, schedule);
860                 if (candidate != null) {
861                     if (!modified2OriginalSchedules.containsKey(candidate)) {
862                         conflicts.put(candidate, true);
863                     }
864                     recordings.remove(candidate);
865                     recordings.add(schedule);
866                     if (modified2OriginalSchedules.containsKey(schedule)) {
867                         // Schedule has been modified, which means it's already conflicted.
868                         // Modify its state to partially conflicted.
869                         conflicts.put(modified2OriginalSchedules.get(schedule), true);
870                     }
871                 } else {
872                     if (!modified2OriginalSchedules.containsKey(schedule)) {
873                         // if schedule has been modified, it's already conflicted.
874                         // No need to add it again.
875                         conflicts.put(schedule, false);
876                     }
877                     long earliestEndTime = getEarliestEndTime(recordings);
878                     if (earliestEndTime < schedule.getEndTimeMs()) {
879                         // The schedule can starts when other recording ends even though it's
880                         // clipped.
881                         ScheduledRecording modifiedSchedule = ScheduledRecording.buildFrom(schedule)
882                                 .setStartTimeMs(earliestEndTime).build();
883                         ScheduledRecording originalSchedule =
884                                 modified2OriginalSchedules.getOrDefault(schedule, schedule);
885                         modified2OriginalSchedules.put(modifiedSchedule, originalSchedule);
886                         int insertPosition = Collections.binarySearch(schedulesToCheck,
887                                 modifiedSchedule,
888                                 ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR);
889                         if (insertPosition >= 0) {
890                             schedulesToCheck.add(insertPosition, modifiedSchedule);
891                         } else {
892                             schedulesToCheck.add(-insertPosition - 1, modifiedSchedule);
893                         }
894                     }
895                 }
896             }
897         }
898         // Returns only the schedules with the given range.
899         if (periods != null && !periods.isEmpty()) {
900             for (Iterator<ScheduledRecording> iter = conflicts.keySet().iterator();
901                     iter.hasNext(); ) {
902                 boolean overlapping = false;
903                 ScheduledRecording schedule = iter.next();
904                 for (Range<Long> period : periods) {
905                     if (schedule.isOverLapping(period)) {
906                         overlapping = true;
907                         break;
908                     }
909                 }
910                 if (!overlapping) {
911                     iter.remove();
912                 }
913             }
914         }
915         return conflicts;
916     }
917 
removeFinishedRecordings(List<ScheduledRecording> recordings, long currentTimeMs)918     private static void removeFinishedRecordings(List<ScheduledRecording> recordings,
919             long currentTimeMs) {
920         for (Iterator<ScheduledRecording> iter = recordings.iterator(); iter.hasNext(); ) {
921             if (iter.next().getEndTimeMs() <= currentTimeMs) {
922                 iter.remove();
923             }
924         }
925     }
926 
927     /**
928      * @see InputTaskScheduler#getReplacableTask
929      */
findReplaceableRecording(List<ScheduledRecording> recordings, ScheduledRecording schedule)930     private static ScheduledRecording findReplaceableRecording(List<ScheduledRecording> recordings,
931             ScheduledRecording schedule) {
932         // Returns the recording with the following priority.
933         // 1. The recording with the lowest priority is returned.
934         // 2. If the priorities are the same, the recording which finishes early is returned.
935         // 3. If 1) and 2) are the same, the early created schedule is returned.
936         ScheduledRecording candidate = null;
937         for (ScheduledRecording recording : recordings) {
938             if (schedule.getPriority() > recording.getPriority()) {
939                 if (candidate == null || CANDIDATE_COMPARATOR.compare(candidate, recording) > 0) {
940                     candidate = recording;
941                 }
942             }
943         }
944         return candidate;
945     }
946 
getEarliestEndTime(List<ScheduledRecording> recordings)947     private static long getEarliestEndTime(List<ScheduledRecording> recordings) {
948         long earliest = Long.MAX_VALUE;
949         for (ScheduledRecording recording : recordings) {
950             if (earliest > recording.getEndTimeMs()) {
951                 earliest = recording.getEndTimeMs();
952             }
953         }
954         return earliest;
955     }
956 
957     /**
958      * A listener which is notified the initialization of schedule manager.
959      */
960     public interface OnInitializeListener {
961         /**
962          * Called when the schedule manager has been initialized.
963          */
onInitialize()964         void onInitialize();
965     }
966 
967     /**
968      * A listener which is notified the conflict state change of the schedules.
969      */
970     public interface OnConflictStateChangeListener {
971         /**
972          * Called when the conflicting schedules change.
973          *
974          * @param conflict {@code true} if the {@code schedules} are the new conflicts, otherwise
975          * {@code false}.
976          * @param schedules the schedules
977          */
onConflictStateChange(boolean conflict, ScheduledRecording... schedules)978         void onConflictStateChange(boolean conflict, ScheduledRecording... schedules);
979     }
980 }