blob: 7202dce0785769069e5503ea69b597becd05a271 [file] [log] [blame]
/*
* 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.TvSingletons;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.Program;
import com.android.tv.data.api.Channel;
import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener;
import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
import com.android.tv.dvr.data.ScheduledRecording;
import com.android.tv.dvr.data.SeriesRecording;
import com.android.tv.dvr.recorder.InputTaskScheduler;
import com.android.tv.util.CompositeComparator;
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
@SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated
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<ScheduledRecording> 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<ScheduledRecording> 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<String, List<ScheduledRecording>> 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<String, Map<Long, ConflictInfo>> mInputConflictInfoMap = new HashMap<>();
private boolean mInitialized;
private final Set<OnInitializeListener> mOnInitializeListeners = new CopyOnWriteArraySet<>();
private final Set<ScheduledRecordingListener> mScheduledRecordingListeners = new ArraySet<>();
private final Set<OnConflictStateChangeListener> mOnConflictStateChangeListeners =
new ArraySet<>();
public DvrScheduleManager(Context context) {
mContext = context;
TvSingletons tvSingletons = TvSingletons.getSingletons(context);
mDataManager = (DvrDataManagerImpl) tvSingletons.getDvrDataManager();
mChannelDataManager = tvSingletons.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 : %s", schedule)) {
// Input removed.
mInputScheduleMap.remove(schedule.getInputId());
mInputConflictInfoMap.remove(schedule.getInputId());
continue;
}
String inputId = input.getId();
List<ScheduledRecording> 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<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
if (schedules != null) {
schedules.remove(schedule);
if (schedules.isEmpty()) {
mInputScheduleMap.remove(inputId);
}
}
Map<Long, ConflictInfo> 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 : %s", schedule)) {
// Input removed.
mInputScheduleMap.remove(schedule.getInputId());
mInputConflictInfoMap.remove(schedule.getInputId());
continue;
}
String inputId = input.getId();
List<ScheduledRecording> 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<ScheduledRecording> 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<Long, ConflictInfo> 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<ScheduledRecording> getStartedRecordings(String inputId) {
if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) {
return Collections.emptyList();
}
List<ScheduledRecording> result = new ArrayList<>();
List<ScheduledRecording> 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<ScheduledRecording> 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<ScheduledRecording> addedConflicts = new ArrayList<>();
List<ScheduledRecording> removedConflicts = new ArrayList<>();
for (String inputId : mInputScheduleMap.keySet()) {
Map<Long, ConflictInfo> oldConflictInfo = mInputConflictInfoMap.get(inputId);
Map<Long, ScheduledRecording> oldConflictMap = new HashMap<>();
if (oldConflictInfo != null) {
for (ConflictInfo conflictInfo : oldConflictInfo.values()) {
oldConflictMap.put(conflictInfo.schedule.getId(), conflictInfo.schedule);
}
}
List<ConflictInfo> conflicts = getConflictingSchedulesInfo(inputId);
if (conflicts.isEmpty()) {
mInputConflictInfoMap.remove(inputId);
} else {
Map<Long, ConflictInfo> 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.
*
* <p>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<ScheduledRecording> 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<Long> peroid, long basePriority) {
List<ScheduledRecording> 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.
*
* <p>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.
*
* <p>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.
*
* <p>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<ScheduledRecording> getConflictingSchedules(Program program) {
SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
SoftPreconditions.checkState(
Program.isProgramValid(program), TAG, "Program is invalid: " + program);
SoftPreconditions.checkState(
program.getStartTimeUtcMillis() < program.getEndTimeUtcMillis(),
TAG,
"Program duration is empty: " + program);
if (!mInitialized
|| !Program.isProgramValid(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.
*
* <p>Any empty list means there is no conflicts.
*/
public List<ScheduledRecording> 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<ScheduledRecording> scheduledRecordingForSeries =
mDataManager.getScheduledRecordings(seriesRecording.getId());
List<ScheduledRecording> 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.
*
* <p>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<ScheduledRecording> 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<ConflictInfo> 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<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId());
if (schedules == null || schedules.isEmpty()) {
return Collections.emptyList();
}
return getConflictingSchedulesInfo(schedules, input.getTunerCount());
}
/**
* Checks if the schedule is conflicting.
*
* <p>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<Long, ConflictInfo> 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.
*
* <p>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<Long, ConflictInfo> 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<ScheduledRecording> 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<ScheduledRecording> getConflictingSchedulesForTune(
String inputId,
long channelId,
long currentTimeMs,
long newPriority,
List<ScheduledRecording> startedRecordings,
int tunerCount) {
boolean channelFound = false;
for (ScheduledRecording schedule : startedRecordings) {
if (schedule.getChannelId() == channelId) {
channelFound = true;
break;
}
}
List<ScheduledRecording> 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.
*
* <p>Note that if the user keeps watching the channel, the channel can be recorded.
*/
public List<ScheduledRecording> 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<ScheduledRecording> 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<ScheduledRecording> getConflictingSchedules(
TvInputInfo input, List<ScheduledRecording> schedulesToAdd) {
SoftPreconditions.checkNotNull(input);
if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
return Collections.emptyList();
}
List<ScheduledRecording> currentSchedules = mInputScheduleMap.get(input.getId());
if (currentSchedules == null || currentSchedules.isEmpty()) {
return Collections.emptyList();
}
return getConflictingSchedules(schedulesToAdd, currentSchedules, input.getTunerCount());
}
@VisibleForTesting
static List<ScheduledRecording> getConflictingSchedulesForWatching(
String inputId,
long channelId,
long currentTimeMs,
long newPriority,
@NonNull List<ScheduledRecording> schedules,
int tunerCount) {
List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules);
List<ScheduledRecording> 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<ScheduledRecording> result = new ArrayList<>();
result.addAll(getConflictingSchedules(schedulesSameChannel, 1));
result.addAll(getConflictingSchedules(schedulesToCheck, tunerCount));
Collections.sort(result, RESULT_COMPARATOR);
return result;
}
@VisibleForTesting
static List<ScheduledRecording> getConflictingSchedules(
List<ScheduledRecording> schedulesToAdd,
List<ScheduledRecording> currentSchedules,
int tunerCount) {
List<ScheduledRecording> schedulesToCheck = new ArrayList<>(currentSchedules);
// When the duplicate schedule is to be added, remove the current duplicate recording.
for (Iterator<ScheduledRecording> 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<Range<Long>> 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<ScheduledRecording> getConflictingSchedules(
List<ScheduledRecording> schedules, int tunerCount) {
return getConflictingSchedules(schedules, tunerCount, null);
}
@VisibleForTesting
static List<ScheduledRecording> getConflictingSchedules(
List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) {
List<ScheduledRecording> result = new ArrayList<>();
for (ConflictInfo conflictInfo :
getConflictingSchedulesInfo(schedules, tunerCount, periods)) {
result.add(conflictInfo.schedule);
}
return result;
}
@VisibleForTesting
static List<ConflictInfo> getConflictingSchedulesInfo(
List<ScheduledRecording> schedules, int tunerCount) {
return getConflictingSchedulesInfo(schedules, tunerCount, null);
}
/**
* This is the core method to calculate all the conflicting schedules (in given periods).
*
* <p>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<ConflictInfo> getConflictingSchedulesInfo(
List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) {
List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules);
// Sort by the same order as that in InputTaskScheduler.
Collections.sort(schedulesToCheck, InputTaskScheduler.getRecordingOrderComparator());
List<ScheduledRecording> recordings = new ArrayList<>();
Map<ScheduledRecording, ConflictInfo> conflicts = new HashMap<>();
Map<ScheduledRecording, ScheduledRecording> 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<ScheduledRecording> iter = conflicts.keySet().iterator();
iter.hasNext(); ) {
boolean overlapping = false;
ScheduledRecording schedule = iter.next();
for (Range<Long> period : periods) {
if (schedule.isOverLapping(period)) {
overlapping = true;
break;
}
}
if (!overlapping) {
iter.remove();
}
}
}
List<ConflictInfo> result = new ArrayList<>(conflicts.values());
Collections.sort(
result,
(ConflictInfo lhs, ConflictInfo rhs) ->
RESULT_COMPARATOR.compare(lhs.schedule, rhs.schedule));
return result;
}
private static void removeFinishedRecordings(
List<ScheduledRecording> recordings, long currentTimeMs) {
for (Iterator<ScheduledRecording> iter = recordings.iterator(); iter.hasNext(); ) {
if (iter.next().getEndTimeMs() <= currentTimeMs) {
iter.remove();
}
}
}
/** @see InputTaskScheduler#getReplacableTask */
private static ScheduledRecording findReplaceableRecording(
List<ScheduledRecording> 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<ScheduledRecording> 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.
*
* <p>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);
}
}