blob: 8654e6e7a9aa081d896bd136f239d2c49e252803 [file] [log] [blame]
/*
* Copyright (C) 2016 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.recorder;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.os.Build;
import android.support.annotation.MainThread;
import android.text.TextUtils;
import android.util.ArraySet;
import android.util.Log;
import android.util.LongSparseArray;
import com.android.tv.TvSingletons;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.experiments.Experiments;
import com.android.tv.common.util.CollectionUtils;
import com.android.tv.common.util.SharedPreferencesUtils;
import com.android.tv.data.Program;
import com.android.tv.data.epg.EpgReader;
import com.android.tv.dvr.DvrDataManager;
import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener;
import com.android.tv.dvr.DvrManager;
import com.android.tv.dvr.WritableDvrDataManager;
import com.android.tv.dvr.data.ScheduledRecording;
import com.android.tv.dvr.data.SeasonEpisodeNumber;
import com.android.tv.dvr.data.SeriesInfo;
import com.android.tv.dvr.data.SeriesRecording;
import com.android.tv.dvr.provider.EpisodicProgramLoadTask;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import javax.inject.Provider;
/**
* Creates the {@link com.android.tv.dvr.data.ScheduledRecording}s for the {@link
* com.android.tv.dvr.data.SeriesRecording}.
*
* <p>The current implementation assumes that the series recordings are scheduled only for one
* channel.
*/
@TargetApi(Build.VERSION_CODES.N)
public class SeriesRecordingScheduler {
private static final String TAG = "SeriesRecordingSchd";
private static final boolean DEBUG = false;
private static final String KEY_FETCHED_SERIES_IDS =
"SeriesRecordingScheduler.fetched_series_ids";
@SuppressLint("StaticFieldLeak")
private static SeriesRecordingScheduler sInstance;
/** Creates and returns the {@link SeriesRecordingScheduler}. */
public static synchronized SeriesRecordingScheduler getInstance(Context context) {
if (sInstance == null) {
sInstance = new SeriesRecordingScheduler(context);
}
return sInstance;
}
private final Context mContext;
private final DvrManager mDvrManager;
private final WritableDvrDataManager mDataManager;
private final List<SeriesRecordingUpdateTask> mScheduleTasks = new ArrayList<>();
private final LongSparseArray<FetchSeriesInfoTask> mFetchSeriesInfoTasks =
new LongSparseArray<>();
private final Set<String> mFetchedSeriesIds = new ArraySet<>();
private final SharedPreferences mSharedPreferences;
private boolean mStarted;
private boolean mPaused;
private final Set<Long> mPendingSeriesRecordings = new ArraySet<>();
private final SeriesRecordingListener mSeriesRecordingListener =
new SeriesRecordingListener() {
@Override
public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) {
for (SeriesRecording seriesRecording : seriesRecordings) {
executeFetchSeriesInfoTask(seriesRecording);
}
}
@Override
public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) {
// Cancel the update.
for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator();
iter.hasNext(); ) {
SeriesRecordingUpdateTask task = iter.next();
if (CollectionUtils.subtract(
task.getSeriesRecordings(),
seriesRecordings,
SeriesRecording.ID_COMPARATOR)
.isEmpty()) {
task.cancel(true);
iter.remove();
}
}
for (SeriesRecording seriesRecording : seriesRecordings) {
FetchSeriesInfoTask task =
mFetchSeriesInfoTasks.get(seriesRecording.getId());
if (task != null) {
task.cancel(true);
mFetchSeriesInfoTasks.remove(seriesRecording.getId());
}
}
}
@Override
public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) {
List<SeriesRecording> stopped = new ArrayList<>();
List<SeriesRecording> normal = new ArrayList<>();
for (SeriesRecording r : seriesRecordings) {
if (r.isStopped()) {
stopped.add(r);
} else {
normal.add(r);
}
}
if (!stopped.isEmpty()) {
onSeriesRecordingRemoved(SeriesRecording.toArray(stopped));
}
if (!normal.isEmpty()) {
updateSchedules(normal);
}
}
};
private final ScheduledRecordingListener mScheduledRecordingListener =
new ScheduledRecordingListener() {
@Override
public void onScheduledRecordingAdded(ScheduledRecording... schedules) {
// No need to update series recordings when the new schedule is added.
}
@Override
public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
handleScheduledRecordingChange(Arrays.asList(schedules));
}
@Override
public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
List<ScheduledRecording> schedulesForUpdate = new ArrayList<>();
for (ScheduledRecording r : schedules) {
if ((r.getState() == ScheduledRecording.STATE_RECORDING_FAILED
|| r.getState()
== ScheduledRecording.STATE_RECORDING_CLIPPED)
&& r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET
&& !TextUtils.isEmpty(r.getSeasonNumber())
&& !TextUtils.isEmpty(r.getEpisodeNumber())) {
schedulesForUpdate.add(r);
}
}
if (!schedulesForUpdate.isEmpty()) {
handleScheduledRecordingChange(schedulesForUpdate);
}
}
private void handleScheduledRecordingChange(List<ScheduledRecording> schedules) {
if (schedules.isEmpty()) {
return;
}
Set<Long> seriesRecordingIds = new HashSet<>();
for (ScheduledRecording r : schedules) {
if (r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) {
seriesRecordingIds.add(r.getSeriesRecordingId());
}
}
if (!seriesRecordingIds.isEmpty()) {
List<SeriesRecording> seriesRecordings = new ArrayList<>();
for (Long id : seriesRecordingIds) {
SeriesRecording seriesRecording = mDataManager.getSeriesRecording(id);
if (seriesRecording != null) {
seriesRecordings.add(seriesRecording);
}
}
if (!seriesRecordings.isEmpty()) {
updateSchedules(seriesRecordings);
}
}
}
};
private SeriesRecordingScheduler(Context context) {
mContext = context.getApplicationContext();
TvSingletons tvSingletons = TvSingletons.getSingletons(context);
mDvrManager = tvSingletons.getDvrManager();
mDataManager = (WritableDvrDataManager) tvSingletons.getDvrDataManager();
mSharedPreferences =
context.getSharedPreferences(
SharedPreferencesUtils.SHARED_PREF_SERIES_RECORDINGS, Context.MODE_PRIVATE);
mFetchedSeriesIds.addAll(
mSharedPreferences.getStringSet(KEY_FETCHED_SERIES_IDS, Collections.emptySet()));
}
/** Starts the scheduler. */
@MainThread
public void start() {
SoftPreconditions.checkState(mDataManager.isInitialized());
if (mStarted) {
return;
}
if (DEBUG) Log.d(TAG, "start");
mStarted = true;
mDataManager.addSeriesRecordingListener(mSeriesRecordingListener);
mDataManager.addScheduledRecordingListener(mScheduledRecordingListener);
startFetchingSeriesInfo();
updateSchedules(mDataManager.getSeriesRecordings());
}
@MainThread
public void stop() {
if (!mStarted) {
return;
}
if (DEBUG) Log.d(TAG, "stop");
mStarted = false;
for (int i = 0; i < mFetchSeriesInfoTasks.size(); i++) {
FetchSeriesInfoTask task = mFetchSeriesInfoTasks.get(mFetchSeriesInfoTasks.keyAt(i));
task.cancel(true);
}
mFetchSeriesInfoTasks.clear();
for (SeriesRecordingUpdateTask task : mScheduleTasks) {
task.cancel(true);
}
mScheduleTasks.clear();
mDataManager.removeScheduledRecordingListener(mScheduledRecordingListener);
mDataManager.removeSeriesRecordingListener(mSeriesRecordingListener);
}
private void startFetchingSeriesInfo() {
for (SeriesRecording seriesRecording : mDataManager.getSeriesRecordings()) {
if (!mFetchedSeriesIds.contains(seriesRecording.getSeriesId())) {
executeFetchSeriesInfoTask(seriesRecording);
}
}
}
private void executeFetchSeriesInfoTask(SeriesRecording seriesRecording) {
if (Experiments.CLOUD_EPG.get()) {
FetchSeriesInfoTask task =
new FetchSeriesInfoTask(
seriesRecording,
TvSingletons.getSingletons(mContext).providesEpgReader());
task.execute();
mFetchSeriesInfoTasks.put(seriesRecording.getId(), task);
}
}
/** Pauses the updates of the series recordings. */
public void pauseUpdate() {
if (DEBUG) Log.d(TAG, "Schedule paused");
if (mPaused) {
return;
}
mPaused = true;
if (!mStarted) {
return;
}
for (SeriesRecordingUpdateTask task : mScheduleTasks) {
for (SeriesRecording r : task.getSeriesRecordings()) {
mPendingSeriesRecordings.add(r.getId());
}
task.cancel(true);
}
}
/** Resumes the updates of the series recordings. */
public void resumeUpdate() {
if (DEBUG) Log.d(TAG, "Schedule resumed");
if (!mPaused) {
return;
}
mPaused = false;
if (!mStarted) {
return;
}
if (!mPendingSeriesRecordings.isEmpty()) {
List<SeriesRecording> seriesRecordings = new ArrayList<>();
for (long seriesRecordingId : mPendingSeriesRecordings) {
SeriesRecording seriesRecording =
mDataManager.getSeriesRecording(seriesRecordingId);
if (seriesRecording != null) {
seriesRecordings.add(seriesRecording);
}
}
if (!seriesRecordings.isEmpty()) {
updateSchedules(seriesRecordings);
}
}
}
/**
* Update schedules for the given series recordings. If it's paused, the update will be done
* after it's resumed.
*/
public void updateSchedules(Collection<SeriesRecording> seriesRecordings) {
if (DEBUG) Log.d(TAG, "updateSchedules:" + seriesRecordings);
if (!mStarted) {
if (DEBUG) Log.d(TAG, "Not started yet.");
return;
}
if (mPaused) {
for (SeriesRecording r : seriesRecordings) {
mPendingSeriesRecordings.add(r.getId());
}
if (DEBUG) {
Log.d(
TAG,
"The scheduler has been paused. Adding to the pending list. size="
+ mPendingSeriesRecordings.size());
}
return;
}
Set<SeriesRecording> previousSeriesRecordings = new HashSet<>();
for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator();
iter.hasNext(); ) {
SeriesRecordingUpdateTask task = iter.next();
if (CollectionUtils.containsAny(
task.getSeriesRecordings(), seriesRecordings, SeriesRecording.ID_COMPARATOR)) {
// The task is affected by the seriesRecordings
task.cancel(true);
previousSeriesRecordings.addAll(task.getSeriesRecordings());
iter.remove();
}
}
List<SeriesRecording> seriesRecordingsToUpdate =
CollectionUtils.union(
seriesRecordings, previousSeriesRecordings, SeriesRecording.ID_COMPARATOR);
for (Iterator<SeriesRecording> iter = seriesRecordingsToUpdate.iterator();
iter.hasNext(); ) {
SeriesRecording seriesRecording = mDataManager.getSeriesRecording(iter.next().getId());
if (seriesRecording == null || seriesRecording.isStopped()) {
// Series recording has been removed or stopped.
iter.remove();
}
}
if (seriesRecordingsToUpdate.isEmpty()) {
return;
}
if (needToReadAllChannels(seriesRecordingsToUpdate)) {
SeriesRecordingUpdateTask task =
new SeriesRecordingUpdateTask(seriesRecordingsToUpdate);
mScheduleTasks.add(task);
if (DEBUG) Log.d(TAG, "Added schedule task: " + task);
task.execute();
} else {
for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) {
SeriesRecordingUpdateTask task =
new SeriesRecordingUpdateTask(Collections.singletonList(seriesRecording));
mScheduleTasks.add(task);
if (DEBUG) Log.d(TAG, "Added schedule task: " + task);
task.execute();
}
}
}
private boolean needToReadAllChannels(List<SeriesRecording> seriesRecordingsToUpdate) {
for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) {
if (seriesRecording.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ALL) {
return true;
}
}
return false;
}
/**
* Pick one program per an episode.
*
* <p>Note that the programs which has been already scheduled have the highest priority, and all
* of them are added even though they are the same episodes. That's because the schedules should
* be added to the series recording.
*
* <p>If there are no existing schedules for an episode, one program which starts earlier is
* picked.
*/
private LongSparseArray<List<Program>> pickOneProgramPerEpisode(
List<SeriesRecording> seriesRecordings, List<Program> programs) {
return pickOneProgramPerEpisode(mDataManager, seriesRecordings, programs);
}
/** @see #pickOneProgramPerEpisode(List, List) */
public static LongSparseArray<List<Program>> pickOneProgramPerEpisode(
DvrDataManager dataManager,
List<SeriesRecording> seriesRecordings,
List<Program> programs) {
// Initialize.
LongSparseArray<List<Program>> result = new LongSparseArray<>();
Map<String, Long> seriesRecordingIds = new HashMap<>();
for (SeriesRecording seriesRecording : seriesRecordings) {
result.put(seriesRecording.getId(), new ArrayList<>());
seriesRecordingIds.put(seriesRecording.getSeriesId(), seriesRecording.getId());
}
// Group programs by the episode.
Map<SeasonEpisodeNumber, List<Program>> programsForEpisodeMap = new HashMap<>();
for (Program program : programs) {
long seriesRecordingId = seriesRecordingIds.get(program.getSeriesId());
if (TextUtils.isEmpty(program.getSeasonNumber())
|| TextUtils.isEmpty(program.getEpisodeNumber())) {
// Add all the programs if it doesn't have season number or episode number.
result.get(seriesRecordingId).add(program);
continue;
}
SeasonEpisodeNumber seasonEpisodeNumber =
new SeasonEpisodeNumber(
seriesRecordingId,
program.getSeasonNumber(),
program.getEpisodeNumber());
List<Program> programsForEpisode = programsForEpisodeMap.get(seasonEpisodeNumber);
if (programsForEpisode == null) {
programsForEpisode = new ArrayList<>();
programsForEpisodeMap.put(seasonEpisodeNumber, programsForEpisode);
}
programsForEpisode.add(program);
}
// Pick one program.
for (Entry<SeasonEpisodeNumber, List<Program>> entry : programsForEpisodeMap.entrySet()) {
List<Program> programsForEpisode = entry.getValue();
Collections.sort(
programsForEpisode,
(Program lhs, Program rhs) -> {
// Place the existing schedule first.
boolean lhsScheduled = isProgramScheduled(dataManager, lhs);
boolean rhsScheduled = isProgramScheduled(dataManager, rhs);
if (lhsScheduled && !rhsScheduled) {
return -1;
}
if (!lhsScheduled && rhsScheduled) {
return 1;
}
// Sort by the start time in ascending order.
return lhs.compareTo(rhs);
});
boolean added = false;
// Add all the scheduled programs
List<Program> programsForSeries = result.get(entry.getKey().seriesRecordingId);
for (Program program : programsForEpisode) {
if (isProgramScheduled(dataManager, program)) {
programsForSeries.add(program);
added = true;
} else if (!added) {
programsForSeries.add(program);
break;
}
}
}
return result;
}
private static boolean isProgramScheduled(DvrDataManager dataManager, Program program) {
ScheduledRecording schedule =
dataManager.getScheduledRecordingForProgramId(program.getId());
return schedule != null
&& schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED;
}
private void updateFetchedSeries() {
mSharedPreferences.edit().putStringSet(KEY_FETCHED_SERIES_IDS, mFetchedSeriesIds).apply();
}
/**
* This works only for the existing series recordings. Do not use this task for the "adding
* series recording" UI.
*/
private class SeriesRecordingUpdateTask extends EpisodicProgramLoadTask {
SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings) {
super(mContext, seriesRecordings);
}
@Override
protected void onPostExecute(List<Program> programs) {
if (DEBUG) Log.d(TAG, "onPostExecute: updating schedules with programs:" + programs);
mScheduleTasks.remove(this);
if (programs == null) {
Log.e(
TAG,
"Creating schedules for series recording failed: " + getSeriesRecordings());
return;
}
LongSparseArray<List<Program>> seriesProgramMap =
pickOneProgramPerEpisode(getSeriesRecordings(), programs);
for (SeriesRecording seriesRecording : getSeriesRecordings()) {
// Check the series recording is still valid.
SeriesRecording actualSeriesRecording =
mDataManager.getSeriesRecording(seriesRecording.getId());
if (actualSeriesRecording == null || actualSeriesRecording.isStopped()) {
continue;
}
List<Program> programsToSchedule = seriesProgramMap.get(seriesRecording.getId());
if (mDataManager.getSeriesRecording(seriesRecording.getId()) != null
&& !programsToSchedule.isEmpty()) {
mDvrManager.addScheduleToSeriesRecording(seriesRecording, programsToSchedule);
}
}
}
@Override
protected void onCancelled(List<Program> programs) {
mScheduleTasks.remove(this);
}
@Override
public String toString() {
return "SeriesRecordingUpdateTask:{"
+ "series_recordings="
+ getSeriesRecordings()
+ "}";
}
}
private class FetchSeriesInfoTask extends AsyncTask<Void, Void, SeriesInfo> {
private final SeriesRecording mSeriesRecording;
private final Provider<EpgReader> mEpgReaderProvider;
FetchSeriesInfoTask(
SeriesRecording seriesRecording, Provider<EpgReader> epgReaderProvider) {
mSeriesRecording = seriesRecording;
mEpgReaderProvider = epgReaderProvider;
}
@Override
protected SeriesInfo doInBackground(Void... voids) {
return mEpgReaderProvider.get().getSeriesInfo(mSeriesRecording.getSeriesId());
}
@Override
protected void onPostExecute(SeriesInfo seriesInfo) {
if (seriesInfo != null) {
mDataManager.updateSeriesRecording(
SeriesRecording.buildFrom(mSeriesRecording)
.setTitle(seriesInfo.getTitle())
.setDescription(seriesInfo.getDescription())
.setLongDescription(seriesInfo.getLongDescription())
.setCanonicalGenreIds(seriesInfo.getCanonicalGenreIds())
.setPosterUri(seriesInfo.getPosterUri())
.setPhotoUri(seriesInfo.getPhotoUri())
.build());
mFetchedSeriesIds.add(seriesInfo.getId());
updateFetchedSeries();
}
mFetchSeriesInfoTasks.remove(mSeriesRecording.getId());
}
@Override
protected void onCancelled(SeriesInfo seriesInfo) {
mFetchSeriesInfoTasks.remove(mSeriesRecording.getId());
}
}
}