/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tv.dvr; import android.annotation.TargetApi; import android.content.Context; import android.media.tv.TvInputInfo; import android.os.Build; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; import android.util.ArraySet; import android.util.Range; import com.android.tv.ApplicationSingletons; import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.Program; import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener; import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; import com.android.tv.dvr.recorder.InputTaskScheduler; import com.android.tv.util.CompositeComparator; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; /** * A class to manage the schedules. */ @TargetApi(Build.VERSION_CODES.N) @MainThread public class DvrScheduleManager { private static final String TAG = "DvrScheduleManager"; /** * The default priority of scheduled recording. */ public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1; /** * The default priority of series recording. */ public static final long DEFAULT_SERIES_PRIORITY = DEFAULT_PRIORITY >> 1; // The new priority will have the offset from the existing one. private static final long PRIORITY_OFFSET = 1024; private static final Comparator RESULT_COMPARATOR = new CompositeComparator<>( ScheduledRecording.PRIORITY_COMPARATOR.reversed(), ScheduledRecording.START_TIME_COMPARATOR, ScheduledRecording.ID_COMPARATOR.reversed()); // The candidate comparator should be the consistent with // InputTaskScheduler#CANDIDATE_COMPARATOR. private static final Comparator CANDIDATE_COMPARATOR = new CompositeComparator<>( ScheduledRecording.PRIORITY_COMPARATOR, ScheduledRecording.END_TIME_COMPARATOR, ScheduledRecording.ID_COMPARATOR); private final Context mContext; private final DvrDataManagerImpl mDataManager; private final ChannelDataManager mChannelDataManager; private final Map> mInputScheduleMap = new HashMap<>(); // The inner map is a hash map from scheduled recording to its conflicting status, i.e., // the boolean value true denotes the schedule is just partially conflicting, which means // although there's conflict, it might still be recorded partially. private final Map> mInputConflictInfoMap = new HashMap<>(); private boolean mInitialized; private final Set mOnInitializeListeners = new CopyOnWriteArraySet<>(); private final Set mScheduledRecordingListeners = new ArraySet<>(); private final Set mOnConflictStateChangeListeners = new ArraySet<>(); public DvrScheduleManager(Context context) { mContext = context; ApplicationSingletons appSingletons = TvApplication.getSingletons(context); mDataManager = (DvrDataManagerImpl) appSingletons.getDvrDataManager(); mChannelDataManager = appSingletons.getChannelDataManager(); if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) { buildData(); } else { mDataManager.addDvrScheduleLoadFinishedListener( new OnDvrScheduleLoadFinishedListener() { @Override public void onDvrScheduleLoadFinished() { mDataManager.removeDvrScheduleLoadFinishedListener(this); if (mChannelDataManager.isDbLoadFinished() && !mInitialized) { buildData(); } } }); } ScheduledRecordingListener scheduledRecordingListener = new ScheduledRecordingListener() { @Override public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { if (!mInitialized) { return; } for (ScheduledRecording schedule : scheduledRecordings) { if (!schedule.isNotStarted() && !schedule.isInProgress()) { continue; } TvInputInfo input = Utils .getTvInputInfoForInputId(mContext, schedule.getInputId()); if (!SoftPreconditions.checkArgument(input != null, TAG, "Input was removed for : " + schedule)) { // Input removed. mInputScheduleMap.remove(schedule.getInputId()); mInputConflictInfoMap.remove(schedule.getInputId()); continue; } String inputId = input.getId(); List schedules = mInputScheduleMap.get(inputId); if (schedules == null) { schedules = new ArrayList<>(); mInputScheduleMap.put(inputId, schedules); } schedules.add(schedule); } onSchedulesChanged(); notifyScheduledRecordingAdded(scheduledRecordings); } @Override public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { if (!mInitialized) { return; } for (ScheduledRecording schedule : scheduledRecordings) { TvInputInfo input = Utils .getTvInputInfoForInputId(mContext, schedule.getInputId()); if (input == null) { // Input removed. mInputScheduleMap.remove(schedule.getInputId()); mInputConflictInfoMap.remove(schedule.getInputId()); continue; } String inputId = input.getId(); List schedules = mInputScheduleMap.get(inputId); if (schedules != null) { schedules.remove(schedule); if (schedules.isEmpty()) { mInputScheduleMap.remove(inputId); } } Map conflictInfo = mInputConflictInfoMap.get(inputId); if (conflictInfo != null) { conflictInfo.remove(schedule.getId()); if (conflictInfo.isEmpty()) { mInputConflictInfoMap.remove(inputId); } } } onSchedulesChanged(); notifyScheduledRecordingRemoved(scheduledRecordings); } @Override public void onScheduledRecordingStatusChanged( ScheduledRecording... scheduledRecordings) { if (!mInitialized) { return; } for (ScheduledRecording schedule : scheduledRecordings) { TvInputInfo input = Utils .getTvInputInfoForInputId(mContext, schedule.getInputId()); if (!SoftPreconditions.checkArgument(input != null, TAG, "Input was removed for : " + schedule)) { // Input removed. mInputScheduleMap.remove(schedule.getInputId()); mInputConflictInfoMap.remove(schedule.getInputId()); continue; } String inputId = input.getId(); List schedules = mInputScheduleMap.get(inputId); if (schedules == null) { schedules = new ArrayList<>(); mInputScheduleMap.put(inputId, schedules); } // Compare ID because ScheduledRecording.equals() doesn't work if the state // is changed. for (Iterator i = schedules.iterator(); i.hasNext(); ) { if (i.next().getId() == schedule.getId()) { i.remove(); break; } } if (schedule.isNotStarted() || schedule.isInProgress()) { schedules.add(schedule); } if (schedules.isEmpty()) { mInputScheduleMap.remove(inputId); } // Update conflict list as well Map conflictInfo = mInputConflictInfoMap.get(inputId); if (conflictInfo != null) { ConflictInfo oldConflictInfo = conflictInfo.get(schedule.getId()); if (oldConflictInfo != null) { oldConflictInfo.schedule = schedule; } } } onSchedulesChanged(); notifyScheduledRecordingStatusChanged(scheduledRecordings); } }; mDataManager.addScheduledRecordingListener(scheduledRecordingListener); ChannelDataManager.Listener channelDataManagerListener = new ChannelDataManager.Listener() { @Override public void onLoadFinished() { if (mDataManager.isDvrScheduleLoadFinished() && !mInitialized) { buildData(); } } @Override public void onChannelListUpdated() { if (mDataManager.isDvrScheduleLoadFinished()) { buildData(); } } @Override public void onChannelBrowsableChanged() { } }; mChannelDataManager.addListener(channelDataManagerListener); } /** * Returns the started recordings for the given input. */ private List getStartedRecordings(String inputId) { if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { return Collections.emptyList(); } List result = new ArrayList<>(); List schedules = mInputScheduleMap.get(inputId); if (schedules != null) { for (ScheduledRecording schedule : schedules) { if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { result.add(schedule); } } } return result; } private void buildData() { mInputScheduleMap.clear(); for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) { if (!schedule.isNotStarted() && !schedule.isInProgress()) { continue; } Channel channel = mChannelDataManager.getChannel(schedule.getChannelId()); if (channel != null) { String inputId = channel.getInputId(); // Do not check whether the input is valid or not. The input might be temporarily // invalid. List schedules = mInputScheduleMap.get(inputId); if (schedules == null) { schedules = new ArrayList<>(); mInputScheduleMap.put(inputId, schedules); } schedules.add(schedule); } } if (!mInitialized) { mInitialized = true; notifyInitialize(); } onSchedulesChanged(); } private void onSchedulesChanged() { // TODO: notify conflict state change when some conflicting recording becomes partially // conflicting, vice versa. List addedConflicts = new ArrayList<>(); List removedConflicts = new ArrayList<>(); for (String inputId : mInputScheduleMap.keySet()) { Map oldConflictInfo = mInputConflictInfoMap.get(inputId); Map oldConflictMap = new HashMap<>(); if (oldConflictInfo != null) { for (ConflictInfo conflictInfo : oldConflictInfo.values()) { oldConflictMap.put(conflictInfo.schedule.getId(), conflictInfo.schedule); } } List conflicts = getConflictingSchedulesInfo(inputId); if (conflicts.isEmpty()) { mInputConflictInfoMap.remove(inputId); } else { Map conflictInfos = new HashMap<>(); for (ConflictInfo conflictInfo : conflicts) { conflictInfos.put(conflictInfo.schedule.getId(), conflictInfo); if (oldConflictMap.remove(conflictInfo.schedule.getId()) == null) { addedConflicts.add(conflictInfo.schedule); } } mInputConflictInfoMap.put(inputId, conflictInfos); } removedConflicts.addAll(oldConflictMap.values()); } if (!removedConflicts.isEmpty()) { notifyConflictStateChange(false, ScheduledRecording.toArray(removedConflicts)); } if (!addedConflicts.isEmpty()) { notifyConflictStateChange(true, ScheduledRecording.toArray(addedConflicts)); } } /** * Returns {@code true} if this class has been initialized. */ public boolean isInitialized() { return mInitialized; } /** * Adds a {@link ScheduledRecordingListener}. */ public final void addScheduledRecordingListener(ScheduledRecordingListener listener) { mScheduledRecordingListeners.add(listener); } /** * Removes a {@link ScheduledRecordingListener}. */ public final void removeScheduledRecordingListener(ScheduledRecordingListener listener) { mScheduledRecordingListeners.remove(listener); } /** * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded} for each listener. */ private void notifyScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { for (ScheduledRecordingListener l : mScheduledRecordingListeners) { l.onScheduledRecordingAdded(scheduledRecordings); } } /** * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved} for each listener. */ private void notifyScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { for (ScheduledRecordingListener l : mScheduledRecordingListeners) { l.onScheduledRecordingRemoved(scheduledRecordings); } } /** * Calls {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged} for each listener. */ private void notifyScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { for (ScheduledRecordingListener l : mScheduledRecordingListeners) { l.onScheduledRecordingStatusChanged(scheduledRecordings); } } /** * Adds a {@link OnInitializeListener}. */ public final void addOnInitializeListener(OnInitializeListener listener) { mOnInitializeListeners.add(listener); } /** * Removes a {@link OnInitializeListener}. */ public final void removeOnInitializeListener(OnInitializeListener listener) { mOnInitializeListeners.remove(listener); } /** * Calls {@link OnInitializeListener#onInitialize} for each listener. */ private void notifyInitialize() { for (OnInitializeListener l : mOnInitializeListeners) { l.onInitialize(); } } /** * Adds a {@link OnConflictStateChangeListener}. */ public final void addOnConflictStateChangeListener(OnConflictStateChangeListener listener) { mOnConflictStateChangeListeners.add(listener); } /** * Removes a {@link OnConflictStateChangeListener}. */ public final void removeOnConflictStateChangeListener(OnConflictStateChangeListener listener) { mOnConflictStateChangeListeners.remove(listener); } /** * Calls {@link OnConflictStateChangeListener#onConflictStateChange} for each listener. */ private void notifyConflictStateChange(boolean conflict, ScheduledRecording... scheduledRecordings) { for (OnConflictStateChangeListener l : mOnConflictStateChangeListeners) { l.onConflictStateChange(conflict, scheduledRecordings); } } /** * Returns the priority for the program if it is recorded. *

* The recording will have the higher priority than the existing ones. */ public long suggestNewPriority() { if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { return DEFAULT_PRIORITY; } return suggestHighestPriority(); } private long suggestHighestPriority() { long highestPriority = DEFAULT_PRIORITY - PRIORITY_OFFSET; for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) { if (schedule.getPriority() > highestPriority) { highestPriority = schedule.getPriority(); } } return highestPriority + PRIORITY_OFFSET; } /** * Suggests the higher priority than the schedules which overlap with {@code schedule}. */ public long suggestHighestPriority(ScheduledRecording schedule) { List schedules = mInputScheduleMap.get(schedule.getInputId()); if (schedules == null) { return DEFAULT_PRIORITY; } long highestPriority = Long.MIN_VALUE; for (ScheduledRecording r : schedules) { if (!r.equals(schedule) && r.isOverLapping(schedule) && r.getPriority() > highestPriority) { highestPriority = r.getPriority(); } } if (highestPriority == Long.MIN_VALUE || highestPriority < schedule.getPriority()) { return schedule.getPriority(); } return highestPriority + PRIORITY_OFFSET; } /** * Suggests the higher priority than the schedules which overlap with {@code schedule}. */ public long suggestHighestPriority(String inputId, Range peroid, long basePriority) { List schedules = mInputScheduleMap.get(inputId); if (schedules == null) { return DEFAULT_PRIORITY; } long highestPriority = Long.MIN_VALUE; for (ScheduledRecording r : schedules) { if (r.isOverLapping(peroid) && r.getPriority() > highestPriority) { highestPriority = r.getPriority(); } } if (highestPriority == Long.MIN_VALUE || highestPriority < basePriority) { return basePriority; } return highestPriority + PRIORITY_OFFSET; } /** * Returns the priority for a series recording. *

* The recording will have the higher priority than the existing series. */ public long suggestNewSeriesPriority() { if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { return DEFAULT_SERIES_PRIORITY; } return suggestHighestSeriesPriority(); } /** * Returns the priority for a series recording by order of series recording priority. * * Higher order will have higher priority. */ public static long suggestSeriesPriority(int order) { return DEFAULT_SERIES_PRIORITY + order * PRIORITY_OFFSET; } private long suggestHighestSeriesPriority() { long highestPriority = DEFAULT_SERIES_PRIORITY - PRIORITY_OFFSET; for (SeriesRecording schedule : mDataManager.getSeriesRecordings()) { if (schedule.getPriority() > highestPriority) { highestPriority = schedule.getPriority(); } } return highestPriority + PRIORITY_OFFSET; } /** * Returns a sorted list of all scheduled recordings that will not be recorded if * this program is going to be recorded, with their priorities in decending order. *

* An empty list means there is no conflicts. If there is conflict, a priority higher than * the first recording in the returned list should be assigned to the new schedule of this * program to guarantee the program would be completely recorded. */ public List getConflictingSchedules(Program program) { SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); SoftPreconditions.checkState(Program.isValid(program), TAG, "Program is invalid: " + program); SoftPreconditions.checkState( program.getStartTimeUtcMillis() < program.getEndTimeUtcMillis(), TAG, "Program duration is empty: " + program); if (!mInitialized || !Program.isValid(program) || program.getStartTimeUtcMillis() >= program.getEndTimeUtcMillis()) { return Collections.emptyList(); } TvInputInfo input = Utils.getTvInputInfoForProgram(mContext, program); if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { return Collections.emptyList(); } return getConflictingSchedules(input, Collections.singletonList( ScheduledRecording.builder(input.getId(), program) .setPriority(suggestHighestPriority()) .build())); } /** * Returns list of all conflicting scheduled recordings for the given {@code seriesRecording} * recording. *

* Any empty list means there is no conflicts. */ public List getConflictingSchedules(SeriesRecording seriesRecording) { SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); SoftPreconditions.checkState(seriesRecording != null, TAG, "series recording is null"); if (!mInitialized || seriesRecording == null) { return Collections.emptyList(); } TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, seriesRecording.getInputId()); if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { return Collections.emptyList(); } List scheduledRecordingForSeries = mDataManager.getScheduledRecordings( seriesRecording.getId()); List availableScheduledRecordingForSeries = new ArrayList<>(); for (ScheduledRecording scheduledRecording : scheduledRecordingForSeries) { if (scheduledRecording.isNotStarted() || scheduledRecording.isInProgress()) { availableScheduledRecordingForSeries.add(scheduledRecording); } } if (availableScheduledRecordingForSeries.isEmpty()) { return Collections.emptyList(); } return getConflictingSchedules(input, availableScheduledRecordingForSeries); } /** * Returns a sorted list of all scheduled recordings that will not be recorded if * this channel is going to be recorded, with their priority in decending order. *

* An empty list means there is no conflicts. If there is conflict, a priority higher than * the first recording in the returned list should be assigned to the new schedule of this * channel to guarantee the channel would be completely recorded in the designated time range. */ public List getConflictingSchedules(long channelId, long startTimeMs, long endTimeMs) { SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); SoftPreconditions.checkState(startTimeMs < endTimeMs, TAG, "Recording duration is empty."); if (!mInitialized || channelId == Channel.INVALID_ID || startTimeMs >= endTimeMs) { return Collections.emptyList(); } TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { return Collections.emptyList(); } return getConflictingSchedules(input, Collections.singletonList( ScheduledRecording.builder(input.getId(), channelId, startTimeMs, endTimeMs) .setPriority(suggestHighestPriority()) .build())); } /** * Returns all the scheduled recordings that conflicts and will not be recorded or clipped for * the given input. */ @NonNull private List getConflictingSchedulesInfo(String inputId) { SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, inputId); SoftPreconditions.checkState(input != null, TAG, "Can't find input for : " + inputId); if (!mInitialized || input == null) { return Collections.emptyList(); } List schedules = mInputScheduleMap.get(input.getId()); if (schedules == null || schedules.isEmpty()) { return Collections.emptyList(); } return getConflictingSchedulesInfo(schedules, input.getTunerCount()); } /** * Checks if the schedule is conflicting. * *

Note that the {@code schedule} should be the existing one. If not, this returns * {@code false}. */ public boolean isConflicting(ScheduledRecording schedule) { SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId()); SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : " + schedule.getChannelId()); if (!mInitialized || input == null) { return false; } Map conflicts = mInputConflictInfoMap.get(input.getId()); return conflicts != null && conflicts.containsKey(schedule.getId()); } /** * Checks if the schedule is partially conflicting, i.e., part of the scheduled program might be * recorded even if the priority of the schedule is not raised. *

* If the given schedule is not conflicting or is totally conflicting, i.e., cannot be recorded * at all, this method returns {@code false} in both cases. */ public boolean isPartiallyConflicting(@NonNull ScheduledRecording schedule) { SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId()); SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : " + schedule.getChannelId()); if (!mInitialized || input == null) { return false; } Map conflicts = mInputConflictInfoMap.get(input.getId()); if (conflicts != null) { ConflictInfo conflictInfo = conflicts.get(schedule.getId()); return conflictInfo != null && conflictInfo.partialConflict; } return false; } /** * Returns priority ordered list of all scheduled recordings that will not be recorded if * this channel is tuned to. */ public List getConflictingSchedulesForTune(long channelId) { SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: " + channelId); if (!mInitialized || channelId == Channel.INVALID_ID || input == null) { return Collections.emptyList(); } return getConflictingSchedulesForTune(input.getId(), channelId, System.currentTimeMillis(), suggestHighestPriority(), getStartedRecordings(input.getId()), input.getTunerCount()); } @VisibleForTesting public static List getConflictingSchedulesForTune(String inputId, long channelId, long currentTimeMs, long newPriority, List startedRecordings, int tunerCount) { boolean channelFound = false; for (ScheduledRecording schedule : startedRecordings) { if (schedule.getChannelId() == channelId) { channelFound = true; break; } } List schedules; if (!channelFound) { // The current channel is not being recorded. schedules = new ArrayList<>(startedRecordings); schedules.add(ScheduledRecording .builder(inputId, channelId, currentTimeMs, currentTimeMs + 1) .setPriority(newPriority) .build()); } else { schedules = startedRecordings; } return getConflictingSchedules(schedules, tunerCount); } /** * Returns priority ordered list of all scheduled recordings that will not be recorded if * the user keeps watching this channel. *

* Note that if the user keeps watching the channel, the channel can be recorded. */ public List getConflictingSchedulesForWatching(long channelId) { SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: " + channelId); if (!mInitialized || channelId == Channel.INVALID_ID || input == null) { return Collections.emptyList(); } List schedules = mInputScheduleMap.get(input.getId()); if (schedules == null || schedules.isEmpty()) { return Collections.emptyList(); } return getConflictingSchedulesForWatching(input.getId(), channelId, System.currentTimeMillis(), suggestNewPriority(), schedules, input.getTunerCount()); } private List getConflictingSchedules(TvInputInfo input, List schedulesToAdd) { SoftPreconditions.checkNotNull(input); if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { return Collections.emptyList(); } List currentSchedules = mInputScheduleMap.get(input.getId()); if (currentSchedules == null || currentSchedules.isEmpty()) { return Collections.emptyList(); } return getConflictingSchedules(schedulesToAdd, currentSchedules, input.getTunerCount()); } @VisibleForTesting static List getConflictingSchedulesForWatching(String inputId, long channelId, long currentTimeMs, long newPriority, @NonNull List schedules, int tunerCount) { List schedulesToCheck = new ArrayList<>(schedules); List schedulesSameChannel = new ArrayList<>(); for (ScheduledRecording schedule : schedules) { if (schedule.getChannelId() == channelId) { schedulesSameChannel.add(schedule); schedulesToCheck.remove(schedule); } } // Assume that the user will watch the current channel forever. schedulesToCheck.add(ScheduledRecording .builder(inputId, channelId, currentTimeMs, Long.MAX_VALUE) .setPriority(newPriority) .build()); List result = new ArrayList<>(); result.addAll(getConflictingSchedules(schedulesSameChannel, 1)); result.addAll(getConflictingSchedules(schedulesToCheck, tunerCount)); Collections.sort(result, RESULT_COMPARATOR); return result; } @VisibleForTesting static List getConflictingSchedules(List schedulesToAdd, List currentSchedules, int tunerCount) { List schedulesToCheck = new ArrayList<>(currentSchedules); // When the duplicate schedule is to be added, remove the current duplicate recording. for (Iterator iter = schedulesToCheck.iterator(); iter.hasNext(); ) { ScheduledRecording schedule = iter.next(); for (ScheduledRecording toAdd : schedulesToAdd) { if (schedule.getType() == ScheduledRecording.TYPE_PROGRAM) { if (toAdd.getProgramId() == schedule.getProgramId()) { iter.remove(); break; } } else { if (toAdd.getChannelId() == schedule.getChannelId() && toAdd.getStartTimeMs() == schedule.getStartTimeMs() && toAdd.getEndTimeMs() == schedule.getEndTimeMs()) { iter.remove(); break; } } } } schedulesToCheck.addAll(schedulesToAdd); List> ranges = new ArrayList<>(); for (ScheduledRecording schedule : schedulesToAdd) { ranges.add(new Range<>(schedule.getStartTimeMs(), schedule.getEndTimeMs())); } return getConflictingSchedules(schedulesToCheck, tunerCount, ranges); } /** * Returns all conflicting scheduled recordings for the given schedules and count of tuner. */ public static List getConflictingSchedules( List schedules, int tunerCount) { return getConflictingSchedules(schedules, tunerCount, null); } @VisibleForTesting static List getConflictingSchedules( List schedules, int tunerCount, List> periods) { List result = new ArrayList<>(); for (ConflictInfo conflictInfo : getConflictingSchedulesInfo(schedules, tunerCount, periods)) { result.add(conflictInfo.schedule); } return result; } @VisibleForTesting static List getConflictingSchedulesInfo(List schedules, int tunerCount) { return getConflictingSchedulesInfo(schedules, tunerCount, null); } /** * This is the core method to calculate all the conflicting schedules (in given periods). *

* Note that this method will ignore duplicated schedules with a same hash code. (Please refer * to {@link ScheduledRecording#hashCode}.) * * @return A {@link HashMap} from {@link ScheduledRecording} to {@link Boolean}. The boolean * value denotes if the scheduled recording is partially conflicting, i.e., is possible * to be partially recorded under the given schedules and tuner count {@code true}, * or not {@code false}. */ private static List getConflictingSchedulesInfo( List schedules, int tunerCount, List> periods) { List schedulesToCheck = new ArrayList<>(schedules); // Sort by the same order as that in InputTaskScheduler. Collections.sort(schedulesToCheck, InputTaskScheduler.getRecordingOrderComparator()); List recordings = new ArrayList<>(); Map conflicts = new HashMap<>(); Map modified2OriginalSchedules = new HashMap<>(); // Simulate InputTaskScheduler. while (!schedulesToCheck.isEmpty()) { ScheduledRecording schedule = schedulesToCheck.remove(0); removeFinishedRecordings(recordings, schedule.getStartTimeMs()); if (recordings.size() < tunerCount) { recordings.add(schedule); if (modified2OriginalSchedules.containsKey(schedule)) { // Schedule has been modified, which means it's already conflicted. // Modify its state to partially conflicted. ScheduledRecording originalSchedule = modified2OriginalSchedules.get(schedule); conflicts.put(originalSchedule, new ConflictInfo(originalSchedule, true)); } } else { ScheduledRecording candidate = findReplaceableRecording(recordings, schedule); if (candidate != null) { if (!modified2OriginalSchedules.containsKey(candidate)) { conflicts.put(candidate, new ConflictInfo(candidate, true)); } recordings.remove(candidate); recordings.add(schedule); if (modified2OriginalSchedules.containsKey(schedule)) { // Schedule has been modified, which means it's already conflicted. // Modify its state to partially conflicted. ScheduledRecording originalSchedule = modified2OriginalSchedules.get(schedule); conflicts.put(originalSchedule, new ConflictInfo(originalSchedule, true)); } } else { if (!modified2OriginalSchedules.containsKey(schedule)) { // if schedule has been modified, it's already conflicted. // No need to add it again. conflicts.put(schedule, new ConflictInfo(schedule, false)); } long earliestEndTime = getEarliestEndTime(recordings); if (earliestEndTime < schedule.getEndTimeMs()) { // The schedule can starts when other recording ends even though it's // clipped. ScheduledRecording modifiedSchedule = ScheduledRecording.buildFrom(schedule) .setStartTimeMs(earliestEndTime).build(); ScheduledRecording originalSchedule = modified2OriginalSchedules.getOrDefault(schedule, schedule); modified2OriginalSchedules.put(modifiedSchedule, originalSchedule); int insertPosition = Collections.binarySearch(schedulesToCheck, modifiedSchedule, ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR); if (insertPosition >= 0) { schedulesToCheck.add(insertPosition, modifiedSchedule); } else { schedulesToCheck.add(-insertPosition - 1, modifiedSchedule); } } } } } // Returns only the schedules with the given range. if (periods != null && !periods.isEmpty()) { for (Iterator iter = conflicts.keySet().iterator(); iter.hasNext(); ) { boolean overlapping = false; ScheduledRecording schedule = iter.next(); for (Range period : periods) { if (schedule.isOverLapping(period)) { overlapping = true; break; } } if (!overlapping) { iter.remove(); } } } List result = new ArrayList<>(conflicts.values()); Collections.sort(result, new Comparator() { @Override public int compare(ConflictInfo lhs, ConflictInfo rhs) { return RESULT_COMPARATOR.compare(lhs.schedule, rhs.schedule); } }); return result; } private static void removeFinishedRecordings(List recordings, long currentTimeMs) { for (Iterator iter = recordings.iterator(); iter.hasNext(); ) { if (iter.next().getEndTimeMs() <= currentTimeMs) { iter.remove(); } } } /** * @see InputTaskScheduler#getReplacableTask */ private static ScheduledRecording findReplaceableRecording(List recordings, ScheduledRecording schedule) { // Returns the recording with the following priority. // 1. The recording with the lowest priority is returned. // 2. If the priorities are the same, the recording which finishes early is returned. // 3. If 1) and 2) are the same, the early created schedule is returned. ScheduledRecording candidate = null; for (ScheduledRecording recording : recordings) { if (schedule.getPriority() > recording.getPriority()) { if (candidate == null || CANDIDATE_COMPARATOR.compare(candidate, recording) > 0) { candidate = recording; } } } return candidate; } private static long getEarliestEndTime(List recordings) { long earliest = Long.MAX_VALUE; for (ScheduledRecording recording : recordings) { if (earliest > recording.getEndTimeMs()) { earliest = recording.getEndTimeMs(); } } return earliest; } @VisibleForTesting static class ConflictInfo { public ScheduledRecording schedule; public boolean partialConflict; ConflictInfo(ScheduledRecording schedule, boolean partialConflict) { this.schedule = schedule; this.partialConflict = partialConflict; } } /** * A listener which is notified the initialization of schedule manager. */ public interface OnInitializeListener { /** * Called when the schedule manager has been initialized. */ void onInitialize(); } /** * A listener which is notified the conflict state change of the schedules. */ public interface OnConflictStateChangeListener { /** * Called when the conflicting schedules change. *

* Note that this can be called before * {@link ScheduledRecordingListener#onScheduledRecordingAdded} is called. * * @param conflict {@code true} if the {@code schedules} are the new conflicts, otherwise * {@code false}. * @param schedules the schedules */ void onConflictStateChange(boolean conflict, ScheduledRecording... schedules); } }