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