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