aboutsummaryrefslogtreecommitdiff
path: root/src/com/android/tv/dvr/recorder
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/tv/dvr/recorder')
-rw-r--r--src/com/android/tv/dvr/recorder/ConflictChecker.java280
-rw-r--r--src/com/android/tv/dvr/recorder/DvrRecordingService.java154
-rw-r--r--src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java34
-rw-r--r--src/com/android/tv/dvr/recorder/InputTaskScheduler.java435
-rw-r--r--src/com/android/tv/dvr/recorder/RecordingTask.java530
-rw-r--r--src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java70
-rw-r--r--src/com/android/tv/dvr/recorder/Scheduler.java287
-rw-r--r--src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java562
8 files changed, 2352 insertions, 0 deletions
diff --git a/src/com/android/tv/dvr/recorder/ConflictChecker.java b/src/com/android/tv/dvr/recorder/ConflictChecker.java
new file mode 100644
index 00000000..8aa90116
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/ConflictChecker.java
@@ -0,0 +1,280 @@
+/*
+ * 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.recorder;
+
+import android.annotation.TargetApi;
+import android.content.ContentUris;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Message;
+import android.support.annotation.MainThread;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.InputSessionManager;
+import com.android.tv.InputSessionManager.OnTvViewChannelChangeListener;
+import com.android.tv.MainActivity;
+import com.android.tv.TvApplication;
+import com.android.tv.common.WeakHandler;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.data.ScheduledRecording;
+import com.android.tv.dvr.ui.DvrUiHelper;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Checking the runtime conflict of DVR recording.
+ * <p>
+ * This class runs only while the {@link MainActivity} is resumed and holds the upcoming conflicts.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+@MainThread
+public class ConflictChecker {
+ private static final String TAG = "ConflictChecker";
+ private static final boolean DEBUG = false;
+
+ private static final int MSG_CHECK_CONFLICT = 1;
+
+ private static final long CHECK_RETRY_PERIOD_MS = TimeUnit.SECONDS.toMillis(30);
+
+ /**
+ * To show watch conflict dialog, the start time of the earliest conflicting schedule should be
+ * less than or equal to this time.
+ */
+ private static final long MAX_WATCH_CONFLICT_CHECK_TIME_MS = TimeUnit.MINUTES.toMillis(5);
+ /**
+ * To show watch conflict dialog, the start time of the earliest conflicting schedule should be
+ * greater than or equal to this time.
+ */
+ private static final long MIN_WATCH_CONFLICT_CHECK_TIME_MS = TimeUnit.SECONDS.toMillis(30);
+
+ private final MainActivity mMainActivity;
+ private final ChannelDataManager mChannelDataManager;
+ private final DvrScheduleManager mScheduleManager;
+ private final InputSessionManager mSessionManager;
+ private final ConflictCheckerHandler mHandler = new ConflictCheckerHandler(this);
+
+ private final List<ScheduledRecording> mUpcomingConflicts = new ArrayList<>();
+ private final Set<OnUpcomingConflictChangeListener> mOnUpcomingConflictChangeListeners =
+ new ArraySet<>();
+ private final Map<Long, List<ScheduledRecording>> mCheckedConflictsMap = new HashMap<>();
+
+ private final ScheduledRecordingListener mScheduledRecordingListener =
+ new ScheduledRecordingListener() {
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + scheduledRecordings);
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + scheduledRecordings);
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingStatusChanged: " + scheduledRecordings);
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ }
+ };
+
+ private final OnTvViewChannelChangeListener mOnTvViewChannelChangeListener =
+ new OnTvViewChannelChangeListener() {
+ @Override
+ public void onTvViewChannelChange(@Nullable Uri channelUri) {
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ }
+ };
+
+ private boolean mStarted;
+
+ public ConflictChecker(MainActivity mainActivity) {
+ mMainActivity = mainActivity;
+ ApplicationSingletons appSingletons = TvApplication.getSingletons(mainActivity);
+ mChannelDataManager = appSingletons.getChannelDataManager();
+ mScheduleManager = appSingletons.getDvrScheduleManager();
+ mSessionManager = appSingletons.getInputSessionManager();
+ }
+
+ /**
+ * Starts checking the conflict.
+ */
+ public void start() {
+ if (mStarted) {
+ return;
+ }
+ mStarted = true;
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ mScheduleManager.addScheduledRecordingListener(mScheduledRecordingListener);
+ mSessionManager.addOnTvViewChannelChangeListener(mOnTvViewChannelChangeListener);
+ }
+
+ /**
+ * Stops checking the conflict.
+ */
+ public void stop() {
+ if (!mStarted) {
+ return;
+ }
+ mStarted = false;
+ mSessionManager.removeOnTvViewChannelChangeListener(mOnTvViewChannelChangeListener);
+ mScheduleManager.removeScheduledRecordingListener(mScheduledRecordingListener);
+ mHandler.removeCallbacksAndMessages(null);
+ }
+
+ /**
+ * Returns the upcoming conflicts.
+ */
+ public List<ScheduledRecording> getUpcomingConflicts() {
+ return new ArrayList<>(mUpcomingConflicts);
+ }
+
+ /**
+ * Adds a {@link OnUpcomingConflictChangeListener}.
+ */
+ public void addOnUpcomingConflictChangeListener(OnUpcomingConflictChangeListener listener) {
+ mOnUpcomingConflictChangeListeners.add(listener);
+ }
+
+ /**
+ * Removes the {@link OnUpcomingConflictChangeListener}.
+ */
+ public void removeOnUpcomingConflictChangeListener(OnUpcomingConflictChangeListener listener) {
+ mOnUpcomingConflictChangeListeners.remove(listener);
+ }
+
+ private void notifyUpcomingConflictChanged() {
+ for (OnUpcomingConflictChangeListener l : mOnUpcomingConflictChangeListeners) {
+ l.onUpcomingConflictChange();
+ }
+ }
+
+ /**
+ * Remembers the user's decision to record while watching the channel.
+ */
+ public void setCheckedConflictsForChannel(long mChannelId, List<ScheduledRecording> conflicts) {
+ mCheckedConflictsMap.put(mChannelId, new ArrayList<>(conflicts));
+ }
+
+ void onCheckConflict() {
+ // Checks the conflicting schedules and setup the next re-check time.
+ // If there are upcoming conflicts soon, it opens the conflict dialog.
+ if (DEBUG) Log.d(TAG, "Handling MSG_CHECK_CONFLICT");
+ mHandler.removeMessages(MSG_CHECK_CONFLICT);
+ mUpcomingConflicts.clear();
+ if (!mScheduleManager.isInitialized()
+ || !mChannelDataManager.isDbLoadFinished()) {
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_CONFLICT, CHECK_RETRY_PERIOD_MS);
+ notifyUpcomingConflictChanged();
+ return;
+ }
+ if (mSessionManager.getCurrentTvViewChannelUri() == null) {
+ // As MainActivity is not using a tuner, no need to check the conflict.
+ notifyUpcomingConflictChanged();
+ return;
+ }
+ Uri channelUri = mSessionManager.getCurrentTvViewChannelUri();
+ if (TvContract.isChannelUriForPassthroughInput(channelUri)) {
+ notifyUpcomingConflictChanged();
+ return;
+ }
+ long channelId = ContentUris.parseId(channelUri);
+ Channel channel = mChannelDataManager.getChannel(channelId);
+ // The conflicts caused by watching the channel.
+ List<ScheduledRecording> conflicts = mScheduleManager
+ .getConflictingSchedulesForWatching(channel.getId());
+ long earliestToCheck = Long.MAX_VALUE;
+ long currentTimeMs = System.currentTimeMillis();
+ for (ScheduledRecording schedule : conflicts) {
+ long startTimeMs = schedule.getStartTimeMs();
+ if (startTimeMs < currentTimeMs + MIN_WATCH_CONFLICT_CHECK_TIME_MS) {
+ // The start time of the upcoming conflict remains less than the minimum
+ // check time.
+ continue;
+ }
+ if (startTimeMs > currentTimeMs + MAX_WATCH_CONFLICT_CHECK_TIME_MS) {
+ // The start time of the upcoming conflict remains greater than the
+ // maximum check time. Setup the next re-check time.
+ long nextCheckTimeMs = startTimeMs - MAX_WATCH_CONFLICT_CHECK_TIME_MS;
+ if (earliestToCheck > nextCheckTimeMs) {
+ earliestToCheck = nextCheckTimeMs;
+ }
+ } else {
+ // Found upcoming conflicts which will start soon.
+ mUpcomingConflicts.add(schedule);
+ // The schedule will be removed from the "upcoming conflict" when the
+ // recording is almost started.
+ long nextCheckTimeMs = startTimeMs - MIN_WATCH_CONFLICT_CHECK_TIME_MS;
+ if (earliestToCheck > nextCheckTimeMs) {
+ earliestToCheck = nextCheckTimeMs;
+ }
+ }
+ }
+ if (earliestToCheck != Long.MAX_VALUE) {
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_CONFLICT,
+ earliestToCheck - currentTimeMs);
+ }
+ if (DEBUG) Log.d(TAG, "upcoming conflicts: " + mUpcomingConflicts);
+ notifyUpcomingConflictChanged();
+ if (!mUpcomingConflicts.isEmpty()
+ && !DvrUiHelper.isChannelWatchConflictDialogShown(mMainActivity)) {
+ // Don't show the conflict dialog if the user already knows.
+ List<ScheduledRecording> checkedConflicts = mCheckedConflictsMap.get(
+ channel.getId());
+ if (checkedConflicts == null
+ || !checkedConflicts.containsAll(mUpcomingConflicts)) {
+ DvrUiHelper.showChannelWatchConflictDialog(mMainActivity, channel);
+ }
+ }
+ }
+
+ private static class ConflictCheckerHandler extends WeakHandler<ConflictChecker> {
+ ConflictCheckerHandler(ConflictChecker conflictChecker) {
+ super(conflictChecker);
+ }
+
+ @Override
+ protected void handleMessage(Message msg, @NonNull ConflictChecker conflictChecker) {
+ switch (msg.what) {
+ case MSG_CHECK_CONFLICT:
+ conflictChecker.onCheckConflict();
+ break;
+ }
+ }
+ }
+
+ /**
+ * A listener for the change of upcoming conflicts.
+ */
+ public interface OnUpcomingConflictChangeListener {
+ void onUpcomingConflictChange();
+ }
+}
diff --git a/src/com/android/tv/dvr/recorder/DvrRecordingService.java b/src/com/android/tv/dvr/recorder/DvrRecordingService.java
new file mode 100644
index 00000000..08ffaf86
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/DvrRecordingService.java
@@ -0,0 +1,154 @@
+/*
+ * 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.recorder;
+
+import android.app.AlarmManager;
+import android.app.Notification;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.InputSessionManager;
+import com.android.tv.InputSessionManager.OnRecordingSessionChangeListener;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.dvr.WritableDvrDataManager;
+import com.android.tv.util.Clock;
+import com.android.tv.util.RecurringRunner;
+
+/**
+ * DVR Scheduler service.
+ *
+ * <p> This service is responsible for:
+ * <ul>
+ * <li>Send record commands to TV inputs</li>
+ * <li>Wake up at proper timing for recording</li>
+ * <li>Deconflict schedule, handling overlapping times etc.</li>
+ * <li>
+ *
+ * </ul>
+ *
+ * <p>The service does not stop it self.
+ */
+public class DvrRecordingService extends Service {
+ private static final String TAG = "DvrRecordingService";
+ private static final boolean DEBUG = false;
+ public static final String HANDLER_THREAD_NAME = "DvrRecordingService-handler";
+
+ private static final int ONGOING_NOTIFICATION_ID = 1;
+
+ public static void startService(Context context) {
+ Intent dvrSchedulerIntent = new Intent(context, DvrRecordingService.class);
+ context.startService(dvrSchedulerIntent);
+ }
+
+ private final Clock mClock = Clock.SYSTEM;
+ private RecurringRunner mReaperRunner;
+
+ private Scheduler mScheduler;
+ private HandlerThread mHandlerThread;
+ private InputSessionManager mSessionManager;
+ private boolean mForeground;
+
+ private final OnRecordingSessionChangeListener mOnRecordingSessionChangeListener =
+ new OnRecordingSessionChangeListener() {
+ @Override
+ public void onRecordingSessionChange(final boolean create, final int count) {
+ if (create && !mForeground) {
+ Notification notification =
+ new Notification.Builder(getApplicationContext())
+ .setContentTitle(TAG)
+ .setSmallIcon(R.drawable.ic_dvr)
+ .build();
+ startForeground(ONGOING_NOTIFICATION_ID, notification);
+ mForeground = true;
+ } else if (!create && mForeground && count == 0) {
+ stopForeground(STOP_FOREGROUND_REMOVE);
+ mForeground = false;
+ }
+ }
+ };
+
+ @Override
+ public void onCreate() {
+ TvApplication.setCurrentRunningProcess(this, true);
+ if (DEBUG) Log.d(TAG, "onCreate");
+ super.onCreate();
+ SoftPreconditions.checkFeatureEnabled(this, CommonFeatures.DVR, TAG);
+ ApplicationSingletons singletons = TvApplication.getSingletons(this);
+ WritableDvrDataManager dataManager =
+ (WritableDvrDataManager) singletons.getDvrDataManager();
+ mSessionManager = singletons.getInputSessionManager();
+
+ mSessionManager.addOnRecordingSessionChangeListener(mOnRecordingSessionChangeListener);
+ AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ // mScheduler may have been set for testing.
+ if (mScheduler == null) {
+ mHandlerThread = new HandlerThread(HANDLER_THREAD_NAME);
+ mHandlerThread.start();
+ mScheduler = new Scheduler(mHandlerThread.getLooper(), singletons.getDvrManager(),
+ singletons.getInputSessionManager(), dataManager,
+ singletons.getChannelDataManager(), singletons.getTvInputManagerHelper(), this,
+ mClock, alarmManager);
+ mScheduler.start();
+ }
+ mReaperRunner = new RecurringRunner(this, java.util.concurrent.TimeUnit.DAYS.toMillis(1),
+ new ScheduledProgramReaper(dataManager, mClock), null);
+ mReaperRunner.start();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (DEBUG) Log.d(TAG, "onStartCommand (" + intent + "," + flags + "," + startId + ")");
+ mScheduler.update();
+ return START_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ if (DEBUG) Log.d(TAG, "onDestroy");
+ mReaperRunner.stop();
+ mScheduler.stop();
+ mScheduler = null;
+ if (mHandlerThread != null) {
+ mHandlerThread.quitSafely();
+ mHandlerThread = null;
+ }
+ mSessionManager.removeRecordingSessionChangeListener(mOnRecordingSessionChangeListener);
+ super.onDestroy();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @VisibleForTesting
+ void setScheduler(Scheduler scheduler) {
+ Log.i(TAG, "Setting scheduler for tests to " + scheduler);
+ mScheduler = scheduler;
+ }
+}
diff --git a/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java b/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java
new file mode 100644
index 00000000..8c6ee145
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java
@@ -0,0 +1,34 @@
+/*
+ * 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.recorder;
+
+import com.android.tv.TvApplication;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * Signals the DVR to start recording shows <i>soon</i>.
+ */
+public class DvrStartRecordingReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ TvApplication.setCurrentRunningProcess(context, true);
+ DvrRecordingService.startService(context);
+ }
+}
diff --git a/src/com/android/tv/dvr/recorder/InputTaskScheduler.java b/src/com/android/tv/dvr/recorder/InputTaskScheduler.java
new file mode 100644
index 00000000..46546a76
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/InputTaskScheduler.java
@@ -0,0 +1,435 @@
+/*
+ * 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.content.Context;
+import android.media.tv.TvInputInfo;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.LongSparseArray;
+
+import com.android.tv.InputSessionManager;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.WritableDvrDataManager;
+import com.android.tv.dvr.data.ScheduledRecording;
+import com.android.tv.util.Clock;
+import com.android.tv.util.CompositeComparator;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The scheduler for a TV input.
+ */
+public class InputTaskScheduler {
+ private static final String TAG = "InputTaskScheduler";
+ private static final boolean DEBUG = false;
+
+ private static final int MSG_ADD_SCHEDULED_RECORDING = 1;
+ private static final int MSG_REMOVE_SCHEDULED_RECORDING = 2;
+ private static final int MSG_UPDATE_SCHEDULED_RECORDING = 3;
+ private static final int MSG_BUILD_SCHEDULE = 4;
+ private static final int MSG_STOP_SCHEDULE = 5;
+
+ private static final float MIN_REMAIN_DURATION_PERCENT = 0.05f;
+
+ // The candidate comparator should be the consistent with
+ // DvrScheduleManager#CANDIDATE_COMPARATOR.
+ private static final Comparator<RecordingTask> CANDIDATE_COMPARATOR =
+ new CompositeComparator<>(
+ RecordingTask.PRIORITY_COMPARATOR,
+ RecordingTask.END_TIME_COMPARATOR,
+ RecordingTask.ID_COMPARATOR);
+
+ /**
+ * Returns the comparator which the schedules are sorted with when executed.
+ */
+ public static Comparator<ScheduledRecording> getRecordingOrderComparator() {
+ return ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR;
+ }
+
+ /**
+ * Wraps a {@link RecordingTask} removing it from {@link #mPendingRecordings} when it is done.
+ */
+ public final class HandlerWrapper extends Handler {
+ public static final int MESSAGE_REMOVE = 999;
+ private final long mId;
+ private final RecordingTask mTask;
+
+ HandlerWrapper(Looper looper, ScheduledRecording scheduledRecording,
+ RecordingTask recordingTask) {
+ super(looper, recordingTask);
+ mId = scheduledRecording.getId();
+ mTask = recordingTask;
+ mTask.setHandler(this);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ // The RecordingTask gets a chance first.
+ // It must return false to pass this message to here.
+ if (msg.what == MESSAGE_REMOVE) {
+ if (DEBUG) Log.d(TAG, "done " + mId);
+ mPendingRecordings.remove(mId);
+ }
+ removeCallbacksAndMessages(null);
+ mHandler.removeMessages(MSG_BUILD_SCHEDULE);
+ mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE);
+ super.handleMessage(msg);
+ }
+ }
+
+ private TvInputInfo mInput;
+ private final Looper mLooper;
+ private final ChannelDataManager mChannelDataManager;
+ private final DvrManager mDvrManager;
+ private final WritableDvrDataManager mDataManager;
+ private final InputSessionManager mSessionManager;
+ private final Clock mClock;
+ private final Context mContext;
+
+ private final LongSparseArray<HandlerWrapper> mPendingRecordings = new LongSparseArray<>();
+ private final Map<Long, ScheduledRecording> mWaitingSchedules = new ArrayMap<>();
+ private final Handler mMainThreadHandler;
+ private final Handler mHandler;
+ private final Object mInputLock = new Object();
+ private final RecordingTaskFactory mRecordingTaskFactory;
+
+ public InputTaskScheduler(Context context, TvInputInfo input, Looper looper,
+ ChannelDataManager channelDataManager, DvrManager dvrManager,
+ DvrDataManager dataManager, InputSessionManager sessionManager, Clock clock) {
+ this(context, input, looper, channelDataManager, dvrManager, dataManager, sessionManager,
+ clock, new Handler(Looper.getMainLooper()), null, null);
+ }
+
+ @VisibleForTesting
+ InputTaskScheduler(Context context, TvInputInfo input, Looper looper,
+ ChannelDataManager channelDataManager, DvrManager dvrManager,
+ DvrDataManager dataManager, InputSessionManager sessionManager, Clock clock,
+ Handler mainThreadHandler, @Nullable Handler workerThreadHandler,
+ RecordingTaskFactory recordingTaskFactory) {
+ if (DEBUG) Log.d(TAG, "Creating scheduler for " + input);
+ mContext = context;
+ mInput = input;
+ mLooper = looper;
+ mChannelDataManager = channelDataManager;
+ mDvrManager = dvrManager;
+ mDataManager = (WritableDvrDataManager) dataManager;
+ mSessionManager = sessionManager;
+ mClock = clock;
+ mMainThreadHandler = mainThreadHandler;
+ mRecordingTaskFactory = recordingTaskFactory != null ? recordingTaskFactory
+ : new RecordingTaskFactory() {
+ @Override
+ public RecordingTask createRecordingTask(ScheduledRecording schedule, Channel channel,
+ DvrManager dvrManager, InputSessionManager sessionManager,
+ WritableDvrDataManager dataManager, Clock clock) {
+ return new RecordingTask(mContext, schedule, channel, mDvrManager, mSessionManager,
+ mDataManager, mClock);
+ }
+ };
+ if (workerThreadHandler == null) {
+ mHandler = new WorkerThreadHandler(looper);
+ } else {
+ mHandler = workerThreadHandler;
+ }
+ }
+
+ /**
+ * Adds a {@link ScheduledRecording}.
+ */
+ public void addSchedule(ScheduledRecording schedule) {
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_ADD_SCHEDULED_RECORDING, schedule));
+ }
+
+ @VisibleForTesting
+ void handleAddSchedule(ScheduledRecording schedule) {
+ if (mPendingRecordings.get(schedule.getId()) != null
+ || mWaitingSchedules.containsKey(schedule.getId())) {
+ return;
+ }
+ mWaitingSchedules.put(schedule.getId(), schedule);
+ mHandler.removeMessages(MSG_BUILD_SCHEDULE);
+ mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE);
+ }
+
+ /**
+ * Removes the {@link ScheduledRecording}.
+ */
+ public void removeSchedule(ScheduledRecording schedule) {
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_REMOVE_SCHEDULED_RECORDING, schedule));
+ }
+
+ @VisibleForTesting
+ void handleRemoveSchedule(ScheduledRecording schedule) {
+ HandlerWrapper wrapper = mPendingRecordings.get(schedule.getId());
+ if (wrapper != null) {
+ wrapper.mTask.cancel();
+ return;
+ }
+ if (mWaitingSchedules.containsKey(schedule.getId())) {
+ mWaitingSchedules.remove(schedule.getId());
+ mHandler.removeMessages(MSG_BUILD_SCHEDULE);
+ mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE);
+ }
+ }
+
+ /**
+ * Updates the {@link ScheduledRecording}.
+ */
+ public void updateSchedule(ScheduledRecording schedule) {
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_UPDATE_SCHEDULED_RECORDING, schedule));
+ }
+
+ @VisibleForTesting
+ void handleUpdateSchedule(ScheduledRecording schedule) {
+ HandlerWrapper wrapper = mPendingRecordings.get(schedule.getId());
+ if (wrapper != null) {
+ if (schedule.getStartTimeMs() > mClock.currentTimeMillis()
+ && schedule.getStartTimeMs() > wrapper.mTask.getStartTimeMs()) {
+ // It shouldn't have started. Cancel and put to the waiting list.
+ // The schedules will be rebuilt when the task is removed.
+ // The reschedule is called in Scheduler.
+ wrapper.mTask.cancel();
+ mWaitingSchedules.put(schedule.getId(), schedule);
+ return;
+ }
+ wrapper.sendMessage(wrapper.obtainMessage(RecordingTask.MSG_UDPATE_SCHEDULE, schedule));
+ return;
+ }
+ if (mWaitingSchedules.containsKey(schedule.getId())) {
+ mWaitingSchedules.put(schedule.getId(), schedule);
+ mHandler.removeMessages(MSG_BUILD_SCHEDULE);
+ mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE);
+ }
+ }
+
+ /**
+ * Updates the TV input.
+ */
+ public void updateTvInputInfo(TvInputInfo input) {
+ synchronized (mInputLock) {
+ mInput = input;
+ }
+ }
+
+ /**
+ * Stops the input task scheduler.
+ */
+ public void stop() {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.sendEmptyMessage(MSG_STOP_SCHEDULE);
+ }
+
+ private void handleStopSchedule() {
+ mWaitingSchedules.clear();
+ int size = mPendingRecordings.size();
+ for (int i = 0; i < size; ++i) {
+ RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask;
+ task.cleanUp();
+ }
+ }
+
+ @VisibleForTesting
+ void handleBuildSchedule() {
+ if (mWaitingSchedules.isEmpty()) {
+ return;
+ }
+ long currentTimeMs = mClock.currentTimeMillis();
+ // Remove past schedules.
+ for (Iterator<ScheduledRecording> iter = mWaitingSchedules.values().iterator();
+ iter.hasNext(); ) {
+ ScheduledRecording schedule = iter.next();
+ if (schedule.getEndTimeMs() - currentTimeMs
+ <= MIN_REMAIN_DURATION_PERCENT * schedule.getDuration()) {
+ fail(schedule);
+ iter.remove();
+ }
+ }
+ if (mWaitingSchedules.isEmpty()) {
+ return;
+ }
+ // Record the schedules which should start now.
+ List<ScheduledRecording> schedulesToStart = new ArrayList<>();
+ for (ScheduledRecording schedule : mWaitingSchedules.values()) {
+ if (schedule.getState() != ScheduledRecording.STATE_RECORDING_CANCELED
+ && schedule.getStartTimeMs() - RecordingTask.RECORDING_EARLY_START_OFFSET_MS
+ <= currentTimeMs && schedule.getEndTimeMs() > currentTimeMs) {
+ schedulesToStart.add(schedule);
+ }
+ }
+ // The schedules will be executed with the following order.
+ // 1. The schedule which starts early. It can be replaced later when the schedule with the
+ // higher priority needs to start.
+ // 2. The schedule with the higher priority. It can be replaced later when the schedule with
+ // the higher priority needs to start.
+ // 3. The schedule which was created recently.
+ Collections.sort(schedulesToStart, getRecordingOrderComparator());
+ int tunerCount;
+ synchronized (mInputLock) {
+ tunerCount = mInput.canRecord() ? mInput.getTunerCount() : 0;
+ }
+ for (ScheduledRecording schedule : schedulesToStart) {
+ if (hasTaskWhichFinishEarlier(schedule)) {
+ // If there is a schedule which finishes earlier than the new schedule, rebuild the
+ // schedules after it finishes.
+ return;
+ }
+ if (mPendingRecordings.size() < tunerCount) {
+ // Tuners available.
+ createRecordingTask(schedule).start();
+ mWaitingSchedules.remove(schedule.getId());
+ } else {
+ // No available tuners.
+ RecordingTask task = getReplacableTask(schedule);
+ if (task != null) {
+ task.stop();
+ // Just return. The schedules will be rebuilt after the task is stopped.
+ return;
+ }
+ }
+ }
+ if (mWaitingSchedules.isEmpty()) {
+ return;
+ }
+ // Set next scheduling.
+ long earliest = Long.MAX_VALUE;
+ for (ScheduledRecording schedule : mWaitingSchedules.values()) {
+ // The conflicting schedules will be removed if they end before conflicting resolved.
+ if (schedulesToStart.contains(schedule)) {
+ if (earliest > schedule.getEndTimeMs()) {
+ earliest = schedule.getEndTimeMs();
+ }
+ } else {
+ if (earliest > schedule.getStartTimeMs()
+ - RecordingTask.RECORDING_EARLY_START_OFFSET_MS) {
+ earliest = schedule.getStartTimeMs()
+ - RecordingTask.RECORDING_EARLY_START_OFFSET_MS;
+ }
+ }
+ }
+ mHandler.sendEmptyMessageDelayed(MSG_BUILD_SCHEDULE, earliest - currentTimeMs);
+ }
+
+ private RecordingTask createRecordingTask(ScheduledRecording schedule) {
+ Channel channel = mChannelDataManager.getChannel(schedule.getChannelId());
+ RecordingTask recordingTask = mRecordingTaskFactory.createRecordingTask(schedule, channel,
+ mDvrManager, mSessionManager, mDataManager, mClock);
+ HandlerWrapper handlerWrapper = new HandlerWrapper(mLooper, schedule, recordingTask);
+ mPendingRecordings.put(schedule.getId(), handlerWrapper);
+ return recordingTask;
+ }
+
+ private boolean hasTaskWhichFinishEarlier(ScheduledRecording schedule) {
+ int size = mPendingRecordings.size();
+ for (int i = 0; i < size; ++i) {
+ RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask;
+ if (task.getEndTimeMs() <= schedule.getStartTimeMs()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private RecordingTask getReplacableTask(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.
+ int size = mPendingRecordings.size();
+ RecordingTask candidate = null;
+ for (int i = 0; i < size; ++i) {
+ RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask;
+ if (schedule.getPriority() > task.getPriority()) {
+ if (candidate == null || CANDIDATE_COMPARATOR.compare(candidate, task) > 0) {
+ candidate = task;
+ }
+ }
+ }
+ return candidate;
+ }
+
+ private void fail(ScheduledRecording schedule) {
+ // It's called when the scheduling has been failed without creating RecordingTask.
+ runOnMainHandler(new Runnable() {
+ @Override
+ public void run() {
+ ScheduledRecording scheduleInManager =
+ mDataManager.getScheduledRecording(schedule.getId());
+ if (scheduleInManager != null) {
+ // The schedule should be updated based on the object from DataManager in case
+ // when it has been updated.
+ mDataManager.changeState(scheduleInManager,
+ ScheduledRecording.STATE_RECORDING_FAILED);
+ }
+ }
+ });
+ }
+
+ private void runOnMainHandler(Runnable runnable) {
+ if (Looper.myLooper() == mMainThreadHandler.getLooper()) {
+ runnable.run();
+ } else {
+ mMainThreadHandler.post(runnable);
+ }
+ }
+
+ @VisibleForTesting
+ interface RecordingTaskFactory {
+ RecordingTask createRecordingTask(ScheduledRecording scheduledRecording, Channel channel,
+ DvrManager dvrManager, InputSessionManager sessionManager,
+ WritableDvrDataManager dataManager, Clock clock);
+ }
+
+ private class WorkerThreadHandler extends Handler {
+ public WorkerThreadHandler(Looper looper) {
+ super(looper);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_ADD_SCHEDULED_RECORDING:
+ handleAddSchedule((ScheduledRecording) msg.obj);
+ break;
+ case MSG_REMOVE_SCHEDULED_RECORDING:
+ handleRemoveSchedule((ScheduledRecording) msg.obj);
+ break;
+ case MSG_UPDATE_SCHEDULED_RECORDING:
+ handleUpdateSchedule((ScheduledRecording) msg.obj);
+ case MSG_BUILD_SCHEDULE:
+ handleBuildSchedule();
+ break;
+ case MSG_STOP_SCHEDULE:
+ handleStopSchedule();
+ break;
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/recorder/RecordingTask.java b/src/com/android/tv/dvr/recorder/RecordingTask.java
new file mode 100644
index 00000000..c3314dde
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/RecordingTask.java
@@ -0,0 +1,530 @@
+/*
+ * 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.recorder;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.media.tv.TvContract;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvRecordingClient.RecordingCallback;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.support.annotation.VisibleForTesting;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.android.tv.InputSessionManager;
+import com.android.tv.InputSessionManager.RecordingSession;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Channel;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.WritableDvrDataManager;
+import com.android.tv.dvr.data.ScheduledRecording;
+import com.android.tv.dvr.recorder.InputTaskScheduler.HandlerWrapper;
+import com.android.tv.util.Clock;
+import com.android.tv.util.Utils;
+
+import java.util.Comparator;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A Handler that actually starts and stop a recording at the right time.
+ *
+ * <p>This is run on the looper of thread named {@value DvrRecordingService#HANDLER_THREAD_NAME}.
+ * There is only one looper so messages must be handled quickly or start a separate thread.
+ */
+@WorkerThread
+@VisibleForTesting
+@TargetApi(Build.VERSION_CODES.N)
+public class RecordingTask extends RecordingCallback implements Handler.Callback,
+ DvrManager.Listener {
+ private static final String TAG = "RecordingTask";
+ private static final boolean DEBUG = false;
+
+ /**
+ * Compares the end time in ascending order.
+ */
+ public static final Comparator<RecordingTask> END_TIME_COMPARATOR
+ = new Comparator<RecordingTask>() {
+ @Override
+ public int compare(RecordingTask lhs, RecordingTask rhs) {
+ return Long.compare(lhs.getEndTimeMs(), rhs.getEndTimeMs());
+ }
+ };
+
+ /**
+ * Compares ID in ascending order.
+ */
+ public static final Comparator<RecordingTask> ID_COMPARATOR
+ = new Comparator<RecordingTask>() {
+ @Override
+ public int compare(RecordingTask lhs, RecordingTask rhs) {
+ return Long.compare(lhs.getScheduleId(), rhs.getScheduleId());
+ }
+ };
+
+ /**
+ * Compares the priority in ascending order.
+ */
+ public static final Comparator<RecordingTask> PRIORITY_COMPARATOR
+ = new Comparator<RecordingTask>() {
+ @Override
+ public int compare(RecordingTask lhs, RecordingTask rhs) {
+ return Long.compare(lhs.getPriority(), rhs.getPriority());
+ }
+ };
+
+ @VisibleForTesting
+ static final int MSG_INITIALIZE = 1;
+ @VisibleForTesting
+ static final int MSG_START_RECORDING = 2;
+ @VisibleForTesting
+ static final int MSG_STOP_RECORDING = 3;
+ /**
+ * Message to update schedule.
+ */
+ public static final int MSG_UDPATE_SCHEDULE = 4;
+
+ /**
+ * The time when the start command will be sent before the recording starts.
+ */
+ public static final long RECORDING_EARLY_START_OFFSET_MS = TimeUnit.SECONDS.toMillis(3);
+ /**
+ * If the recording starts later than the scheduled start time or ends before the scheduled end
+ * time, it's considered as clipped.
+ */
+ private static final long CLIPPED_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5);
+
+ @VisibleForTesting
+ enum State {
+ NOT_STARTED,
+ SESSION_ACQUIRED,
+ CONNECTION_PENDING,
+ CONNECTED,
+ RECORDING_STARTED,
+ RECORDING_STOP_REQUESTED,
+ FINISHED,
+ ERROR,
+ RELEASED,
+ }
+ private final InputSessionManager mSessionManager;
+ private final DvrManager mDvrManager;
+ private final Context mContext;
+
+ private final WritableDvrDataManager mDataManager;
+ private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
+ private RecordingSession mRecordingSession;
+ private Handler mHandler;
+ private ScheduledRecording mScheduledRecording;
+ private final Channel mChannel;
+ private State mState = State.NOT_STARTED;
+ private final Clock mClock;
+ private boolean mStartedWithClipping;
+ private Uri mRecordedProgramUri;
+ private boolean mCanceled;
+
+ RecordingTask(Context context, ScheduledRecording scheduledRecording, Channel channel,
+ DvrManager dvrManager, InputSessionManager sessionManager,
+ WritableDvrDataManager dataManager, Clock clock) {
+ mContext = context;
+ mScheduledRecording = scheduledRecording;
+ mChannel = channel;
+ mSessionManager = sessionManager;
+ mDataManager = dataManager;
+ mClock = clock;
+ mDvrManager = dvrManager;
+
+ if (DEBUG) Log.d(TAG, "created recording task " + mScheduledRecording);
+ }
+
+ public void setHandler(Handler handler) {
+ mHandler = handler;
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ if (DEBUG) Log.d(TAG, "handleMessage " + msg);
+ SoftPreconditions.checkState(msg.what == HandlerWrapper.MESSAGE_REMOVE || mHandler != null,
+ TAG, "Null handler trying to handle " + msg);
+ try {
+ switch (msg.what) {
+ case MSG_INITIALIZE:
+ handleInit();
+ break;
+ case MSG_START_RECORDING:
+ handleStartRecording();
+ break;
+ case MSG_STOP_RECORDING:
+ handleStopRecording();
+ break;
+ case MSG_UDPATE_SCHEDULE:
+ handleUpdateSchedule((ScheduledRecording) msg.obj);
+ break;
+ case HandlerWrapper.MESSAGE_REMOVE:
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler = null;
+ release();
+ return false;
+ default:
+ SoftPreconditions.checkArgument(false, TAG, "unexpected message type " + msg);
+ break;
+ }
+ return true;
+ } catch (Exception e) {
+ Log.w(TAG, "Error processing message " + msg + " for " + mScheduledRecording, e);
+ failAndQuit();
+ }
+ return false;
+ }
+
+ @Override
+ public void onDisconnected(String inputId) {
+ if (DEBUG) Log.d(TAG, "onDisconnected(" + inputId + ")");
+ if (mRecordingSession != null && mState != State.FINISHED) {
+ failAndQuit();
+ }
+ }
+
+ @Override
+ public void onConnectionFailed(String inputId) {
+ if (DEBUG) Log.d(TAG, "onConnectionFailed(" + inputId + ")");
+ if (mRecordingSession != null) {
+ failAndQuit();
+ }
+ }
+
+ @Override
+ public void onTuned(Uri channelUri) {
+ if (DEBUG) Log.d(TAG, "onTuned");
+ if (mRecordingSession == null) {
+ return;
+ }
+ mState = State.CONNECTED;
+ if (mHandler == null || !sendEmptyMessageAtAbsoluteTime(MSG_START_RECORDING,
+ mScheduledRecording.getStartTimeMs() - RECORDING_EARLY_START_OFFSET_MS)) {
+ failAndQuit();
+ }
+ }
+
+ @Override
+ public void onRecordingStopped(Uri recordedProgramUri) {
+ if (DEBUG) Log.d(TAG, "onRecordingStopped");
+ if (mRecordingSession == null) {
+ return;
+ }
+ mRecordedProgramUri = recordedProgramUri;
+ mState = State.FINISHED;
+ int state = ScheduledRecording.STATE_RECORDING_FINISHED;
+ if (mStartedWithClipping || mScheduledRecording.getEndTimeMs() - CLIPPED_THRESHOLD_MS
+ > mClock.currentTimeMillis()) {
+ state = ScheduledRecording.STATE_RECORDING_CLIPPED;
+ }
+ updateRecordingState(state);
+ sendRemove();
+ if (mCanceled) {
+ removeRecordedProgram();
+ }
+ }
+
+ @Override
+ public void onError(int reason) {
+ if (DEBUG) Log.d(TAG, "onError reason " + reason);
+ if (mRecordingSession == null) {
+ return;
+ }
+ switch (reason) {
+ case TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE:
+ mMainThreadHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ if (TvApplication.getSingletons(mContext).getMainActivityWrapper()
+ .isResumed()) {
+ ScheduledRecording scheduledRecording = mDataManager
+ .getScheduledRecording(mScheduledRecording.getId());
+ if (scheduledRecording != null) {
+ Toast.makeText(mContext.getApplicationContext(),
+ mContext.getString(R.string
+ .dvr_error_insufficient_space_description_one_recording,
+ scheduledRecording.getProgramDisplayTitle(mContext)),
+ Toast.LENGTH_LONG)
+ .show();
+ }
+ } else {
+ Utils.setRecordingFailedReason(mContext.getApplicationContext(),
+ TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ Utils.addFailedScheduledRecordingInfo(mContext.getApplicationContext(),
+ mScheduledRecording.getProgramDisplayTitle(mContext));
+ }
+ }
+ });
+ // Pass through
+ default:
+ failAndQuit();
+ break;
+ }
+ }
+
+ private void handleInit() {
+ if (DEBUG) Log.d(TAG, "handleInit " + mScheduledRecording);
+ if (mScheduledRecording.getEndTimeMs() < mClock.currentTimeMillis()) {
+ Log.w(TAG, "End time already past, not recording " + mScheduledRecording);
+ failAndQuit();
+ return;
+ }
+ if (mChannel == null) {
+ Log.w(TAG, "Null channel for " + mScheduledRecording);
+ failAndQuit();
+ return;
+ }
+ if (mChannel.getId() != mScheduledRecording.getChannelId()) {
+ Log.w(TAG, "Channel" + mChannel + " does not match scheduled recording "
+ + mScheduledRecording);
+ failAndQuit();
+ return;
+ }
+
+ String inputId = mChannel.getInputId();
+ mRecordingSession = mSessionManager.createRecordingSession(inputId,
+ "recordingTask-" + mScheduledRecording.getId(), this,
+ mHandler, mScheduledRecording.getEndTimeMs());
+ mState = State.SESSION_ACQUIRED;
+ mDvrManager.addListener(this, mHandler);
+ mRecordingSession.tune(inputId, mChannel.getUri());
+ mState = State.CONNECTION_PENDING;
+ }
+
+ private void failAndQuit() {
+ if (DEBUG) Log.d(TAG, "failAndQuit");
+ updateRecordingState(ScheduledRecording.STATE_RECORDING_FAILED);
+ mState = State.ERROR;
+ sendRemove();
+ }
+
+ private void sendRemove() {
+ if (DEBUG) Log.d(TAG, "sendRemove");
+ if (mHandler != null) {
+ mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(
+ HandlerWrapper.MESSAGE_REMOVE));
+ }
+ }
+
+ private void handleStartRecording() {
+ if (DEBUG) Log.d(TAG, "handleStartRecording " + mScheduledRecording);
+ long programId = mScheduledRecording.getProgramId();
+ mRecordingSession.startRecording(programId == ScheduledRecording.ID_NOT_SET ? null
+ : TvContract.buildProgramUri(programId));
+ updateRecordingState(ScheduledRecording.STATE_RECORDING_IN_PROGRESS);
+ // If it starts late, it's clipped.
+ if (mScheduledRecording.getStartTimeMs() + CLIPPED_THRESHOLD_MS
+ < mClock.currentTimeMillis()) {
+ mStartedWithClipping = true;
+ }
+ mState = State.RECORDING_STARTED;
+
+ if (!sendEmptyMessageAtAbsoluteTime(MSG_STOP_RECORDING,
+ mScheduledRecording.getEndTimeMs())) {
+ failAndQuit();
+ }
+ }
+
+ private void handleStopRecording() {
+ if (DEBUG) Log.d(TAG, "handleStopRecording " + mScheduledRecording);
+ mRecordingSession.stopRecording();
+ mState = State.RECORDING_STOP_REQUESTED;
+ }
+
+ private void handleUpdateSchedule(ScheduledRecording schedule) {
+ mScheduledRecording = schedule;
+ // Check end time only. The start time is checked in InputTaskScheduler.
+ if (schedule.getEndTimeMs() != mScheduledRecording.getEndTimeMs()) {
+ if (mRecordingSession != null) {
+ mRecordingSession.setEndTimeMs(schedule.getEndTimeMs());
+ }
+ if (mState == State.RECORDING_STARTED) {
+ mHandler.removeMessages(MSG_STOP_RECORDING);
+ if (!sendEmptyMessageAtAbsoluteTime(MSG_STOP_RECORDING, schedule.getEndTimeMs())) {
+ failAndQuit();
+ }
+ }
+ }
+ }
+
+ @VisibleForTesting
+ State getState() {
+ return mState;
+ }
+
+ private long getScheduleId() {
+ return mScheduledRecording.getId();
+ }
+
+ /**
+ * Returns the priority.
+ */
+ public long getPriority() {
+ return mScheduledRecording.getPriority();
+ }
+
+ /**
+ * Returns the start time of the recording.
+ */
+ public long getStartTimeMs() {
+ return mScheduledRecording.getStartTimeMs();
+ }
+
+ /**
+ * Returns the end time of the recording.
+ */
+ public long getEndTimeMs() {
+ return mScheduledRecording.getEndTimeMs();
+ }
+
+ private void release() {
+ if (mRecordingSession != null) {
+ mSessionManager.releaseRecordingSession(mRecordingSession);
+ mRecordingSession = null;
+ }
+ mDvrManager.removeListener(this);
+ }
+
+ private boolean sendEmptyMessageAtAbsoluteTime(int what, long when) {
+ long now = mClock.currentTimeMillis();
+ long delay = Math.max(0L, when - now);
+ if (DEBUG) {
+ Log.d(TAG, "Sending message " + what + " with a delay of " + delay / 1000
+ + " seconds to arrive at " + Utils.toIsoDateTimeString(when));
+ }
+ return mHandler.sendEmptyMessageDelayed(what, delay);
+ }
+
+ private void updateRecordingState(@ScheduledRecording.RecordingState int state) {
+ if (DEBUG) Log.d(TAG, "Updating the state of " + mScheduledRecording + " to " + state);
+ mScheduledRecording = ScheduledRecording.buildFrom(mScheduledRecording).setState(state)
+ .build();
+ runOnMainThread(new Runnable() {
+ @Override
+ public void run() {
+ ScheduledRecording schedule = mDataManager.getScheduledRecording(
+ mScheduledRecording.getId());
+ if (schedule == null) {
+ // Schedule has been deleted. Delete the recorded program.
+ removeRecordedProgram();
+ } else {
+ // Update the state based on the object in DataManager in case when it has been
+ // updated. mScheduledRecording will be updated from
+ // onScheduledRecordingStateChanged.
+ mDataManager.updateScheduledRecording(ScheduledRecording.buildFrom(schedule)
+ .setState(state).build());
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onStopRecordingRequested(ScheduledRecording recording) {
+ if (recording.getId() != mScheduledRecording.getId()) {
+ return;
+ }
+ stop();
+ }
+
+ /**
+ * Starts the task.
+ */
+ public void start() {
+ mHandler.sendEmptyMessage(MSG_INITIALIZE);
+ }
+
+ /**
+ * Stops the task.
+ */
+ public void stop() {
+ if (DEBUG) Log.d(TAG, "stop");
+ switch (mState) {
+ case RECORDING_STARTED:
+ mHandler.removeMessages(MSG_STOP_RECORDING);
+ handleStopRecording();
+ break;
+ case RECORDING_STOP_REQUESTED:
+ // Do nothing
+ break;
+ case NOT_STARTED:
+ case SESSION_ACQUIRED:
+ case CONNECTION_PENDING:
+ case CONNECTED:
+ case FINISHED:
+ case ERROR:
+ case RELEASED:
+ default:
+ sendRemove();
+ break;
+ }
+ }
+
+ /**
+ * Cancels the task
+ */
+ public void cancel() {
+ if (DEBUG) Log.d(TAG, "cancel");
+ mCanceled = true;
+ stop();
+ removeRecordedProgram();
+ }
+
+ /**
+ * Clean up the task.
+ */
+ public void cleanUp() {
+ if (mState == State.RECORDING_STARTED || mState == State.RECORDING_STOP_REQUESTED) {
+ updateRecordingState(ScheduledRecording.STATE_RECORDING_FAILED);
+ }
+ release();
+ if (mHandler != null) {
+ mHandler.removeCallbacksAndMessages(null);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getName() + "(" + mScheduledRecording + ")";
+ }
+
+ private void removeRecordedProgram() {
+ runOnMainThread(new Runnable() {
+ @Override
+ public void run() {
+ if (mRecordedProgramUri != null) {
+ mDvrManager.removeRecordedProgram(mRecordedProgramUri);
+ }
+ }
+ });
+ }
+
+ private void runOnMainThread(Runnable runnable) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ runnable.run();
+ } else {
+ mMainThreadHandler.post(runnable);
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java b/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java
new file mode 100644
index 00000000..d958c4a1
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java
@@ -0,0 +1,70 @@
+/*
+ * 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.support.annotation.MainThread;
+import android.support.annotation.VisibleForTesting;
+
+import com.android.tv.dvr.WritableDvrDataManager;
+import com.android.tv.dvr.data.ScheduledRecording;
+import com.android.tv.dvr.data.SeriesRecording;
+import com.android.tv.util.Clock;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Deletes {@link ScheduledRecording} older than {@value @DAYS} days.
+ */
+class ScheduledProgramReaper implements Runnable {
+
+ @VisibleForTesting
+ static final int DAYS = 2;
+ private final WritableDvrDataManager mDvrDataManager;
+ private final Clock mClock;
+
+ ScheduledProgramReaper(WritableDvrDataManager dvrDataManager, Clock clock) {
+ mDvrDataManager = dvrDataManager;
+ mClock = clock;
+ }
+
+ @Override
+ @MainThread
+ public void run() {
+ long cutoff = mClock.currentTimeMillis() - TimeUnit.DAYS.toMillis(DAYS);
+ List<ScheduledRecording> toRemove = new ArrayList<>();
+ for (ScheduledRecording r : mDvrDataManager.getAllScheduledRecordings()) {
+ // Do not remove the schedules if it belongs to the series recording and was finished
+ // successfully. The schedule is necessary for checking the scheduled episode of the
+ // series recording.
+ if (r.getEndTimeMs() < cutoff
+ && (r.getSeriesRecordingId() == SeriesRecording.ID_NOT_SET
+ || r.getState() != ScheduledRecording.STATE_RECORDING_FINISHED)) {
+ toRemove.add(r);
+ }
+ }
+ for (ScheduledRecording r : mDvrDataManager.getDeletedSchedules()) {
+ if (r.getEndTimeMs() < cutoff) {
+ toRemove.add(r);
+ }
+ }
+ if (!toRemove.isEmpty()) {
+ mDvrDataManager.removeScheduledRecording(ScheduledRecording.toArray(toRemove));
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/recorder/Scheduler.java b/src/com/android/tv/dvr/recorder/Scheduler.java
new file mode 100644
index 00000000..19e73342
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/Scheduler.java
@@ -0,0 +1,287 @@
+/*
+ * 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.recorder;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvInputManager.TvInputCallback;
+import android.os.Looper;
+import android.support.annotation.MainThread;
+import android.support.annotation.VisibleForTesting;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.Range;
+
+import com.android.tv.InputSessionManager;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.data.ChannelDataManager.Listener;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.WritableDvrDataManager;
+import com.android.tv.dvr.data.ScheduledRecording;
+import com.android.tv.util.Clock;
+import com.android.tv.util.TvInputManagerHelper;
+import com.android.tv.util.Utils;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * The core class to manage schedule and run actual recording.
+ */
+@MainThread
+public class Scheduler extends TvInputCallback implements ScheduledRecordingListener {
+ private static final String TAG = "Scheduler";
+ private static final boolean DEBUG = false;
+
+ private final static long SOON_DURATION_IN_MS = TimeUnit.MINUTES.toMillis(5);
+ @VisibleForTesting final static long MS_TO_WAKE_BEFORE_START = TimeUnit.MINUTES.toMillis(1);
+
+ private final Looper mLooper;
+ private final InputSessionManager mSessionManager;
+ private final WritableDvrDataManager mDataManager;
+ private final DvrManager mDvrManager;
+ private final ChannelDataManager mChannelDataManager;
+ private final TvInputManagerHelper mInputManager;
+ private final Context mContext;
+ private final Clock mClock;
+ private final AlarmManager mAlarmManager;
+
+ private final Map<String, InputTaskScheduler> mInputSchedulerMap = new ArrayMap<>();
+ private long mLastStartTimePendingMs;
+
+ public Scheduler(Looper looper, DvrManager dvrManager, InputSessionManager sessionManager,
+ WritableDvrDataManager dataManager, ChannelDataManager channelDataManager,
+ TvInputManagerHelper inputManager, Context context, Clock clock,
+ AlarmManager alarmManager) {
+ mLooper = looper;
+ mDvrManager = dvrManager;
+ mSessionManager = sessionManager;
+ mDataManager = dataManager;
+ mChannelDataManager = channelDataManager;
+ mInputManager = inputManager;
+ mContext = context;
+ mClock = clock;
+ mAlarmManager = alarmManager;
+ }
+
+ /**
+ * Starts the scheduler.
+ */
+ public void start() {
+ mDataManager.addScheduledRecordingListener(this);
+ mInputManager.addCallback(this);
+ if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) {
+ updateInternal();
+ } else {
+ if (!mDataManager.isDvrScheduleLoadFinished()) {
+ mDataManager.addDvrScheduleLoadFinishedListener(
+ new OnDvrScheduleLoadFinishedListener() {
+ @Override
+ public void onDvrScheduleLoadFinished() {
+ mDataManager.removeDvrScheduleLoadFinishedListener(this);
+ updateInternal();
+ }
+ });
+ }
+ if (!mChannelDataManager.isDbLoadFinished()) {
+ mChannelDataManager.addListener(new Listener() {
+ @Override
+ public void onLoadFinished() {
+ mChannelDataManager.removeListener(this);
+ updateInternal();
+ }
+
+ @Override
+ public void onChannelListUpdated() { }
+
+ @Override
+ public void onChannelBrowsableChanged() { }
+ });
+ }
+ }
+ }
+
+ /**
+ * Stops the scheduler.
+ */
+ public void stop() {
+ for (InputTaskScheduler inputTaskScheduler : mInputSchedulerMap.values()) {
+ inputTaskScheduler.stop();
+ }
+ mInputManager.removeCallback(this);
+ mDataManager.removeScheduledRecordingListener(this);
+ }
+
+ private void updatePendingRecordings() {
+ List<ScheduledRecording> scheduledRecordings = mDataManager
+ .getScheduledRecordings(new Range<>(mLastStartTimePendingMs,
+ mClock.currentTimeMillis() + SOON_DURATION_IN_MS),
+ ScheduledRecording.STATE_RECORDING_NOT_STARTED);
+ for (ScheduledRecording r : scheduledRecordings) {
+ scheduleRecordingSoon(r);
+ }
+ }
+
+ /**
+ * Start recording that will happen soon, and set the next alarm time.
+ */
+ public void update() {
+ if (DEBUG) Log.d(TAG, "update");
+ updateInternal();
+ }
+
+ private void updateInternal() {
+ if (isInitialized()) {
+ updatePendingRecordings();
+ updateNextAlarm();
+ }
+ }
+
+ private boolean isInitialized() {
+ return mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished();
+ }
+
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... schedules) {
+ if (DEBUG) Log.d(TAG, "added " + Arrays.asList(schedules));
+ if (!isInitialized()) {
+ return;
+ }
+ handleScheduleChange(schedules);
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
+ if (DEBUG) Log.d(TAG, "removed " + Arrays.asList(schedules));
+ if (!isInitialized()) {
+ return;
+ }
+ boolean needToUpdateAlarm = false;
+ for (ScheduledRecording schedule : schedules) {
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(schedule.getInputId());
+ if (scheduler != null) {
+ scheduler.removeSchedule(schedule);
+ needToUpdateAlarm = true;
+ }
+ }
+ if (needToUpdateAlarm) {
+ updateNextAlarm();
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
+ if (DEBUG) Log.d(TAG, "state changed " + Arrays.asList(schedules));
+ if (!isInitialized()) {
+ return;
+ }
+ // Update the recordings.
+ for (ScheduledRecording schedule : schedules) {
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(schedule.getInputId());
+ if (scheduler != null) {
+ scheduler.updateSchedule(schedule);
+ }
+ }
+ handleScheduleChange(schedules);
+ }
+
+ private void handleScheduleChange(ScheduledRecording... schedules) {
+ boolean needToUpdateAlarm = false;
+ for (ScheduledRecording schedule : schedules) {
+ if (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) {
+ if (startsWithin(schedule, SOON_DURATION_IN_MS)) {
+ scheduleRecordingSoon(schedule);
+ } else {
+ needToUpdateAlarm = true;
+ }
+ }
+ }
+ if (needToUpdateAlarm) {
+ updateNextAlarm();
+ }
+ }
+
+ private void scheduleRecordingSoon(ScheduledRecording schedule) {
+ TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId());
+ if (input == null) {
+ Log.e(TAG, "Can't find input for " + schedule);
+ mDataManager.changeState(schedule, ScheduledRecording.STATE_RECORDING_FAILED);
+ return;
+ }
+ if (!input.canRecord() || input.getTunerCount() <= 0) {
+ Log.e(TAG, "TV input doesn't support recording: " + input);
+ mDataManager.changeState(schedule, ScheduledRecording.STATE_RECORDING_FAILED);
+ return;
+ }
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId());
+ if (scheduler == null) {
+ scheduler = new InputTaskScheduler(mContext, input, mLooper, mChannelDataManager,
+ mDvrManager, mDataManager, mSessionManager, mClock);
+ mInputSchedulerMap.put(input.getId(), scheduler);
+ }
+ scheduler.addSchedule(schedule);
+ if (mLastStartTimePendingMs < schedule.getStartTimeMs()) {
+ mLastStartTimePendingMs = schedule.getStartTimeMs();
+ }
+ }
+
+ private void updateNextAlarm() {
+ long nextStartTime = mDataManager.getNextScheduledStartTimeAfter(
+ Math.max(mLastStartTimePendingMs, mClock.currentTimeMillis()));
+ if (nextStartTime != DvrDataManager.NEXT_START_TIME_NOT_FOUND) {
+ long wakeAt = nextStartTime - MS_TO_WAKE_BEFORE_START;
+ if (DEBUG) Log.d(TAG, "Set alarm to record at " + wakeAt);
+ Intent intent = new Intent(mContext, DvrStartRecordingReceiver.class);
+ PendingIntent alarmIntent = PendingIntent.getBroadcast(mContext, 0, intent, 0);
+ // This will cancel the previous alarm.
+ mAlarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, wakeAt, alarmIntent);
+ } else {
+ if (DEBUG) Log.d(TAG, "No future recording, alarm not set");
+ }
+ }
+
+ @VisibleForTesting
+ boolean startsWithin(ScheduledRecording scheduledRecording, long durationInMs) {
+ return mClock.currentTimeMillis() >= scheduledRecording.getStartTimeMs() - durationInMs;
+ }
+
+ // No need to remove input task scheduler when the input is removed. If the input is removed
+ // temporarily, the scheduler should keep the non-started schedules.
+ @Override
+ public void onInputUpdated(String inputId) {
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(inputId);
+ if (scheduler != null) {
+ scheduler.updateTvInputInfo(Utils.getTvInputInfoForInputId(mContext, inputId));
+ }
+ }
+
+ @Override
+ public void onTvInputInfoUpdated(TvInputInfo input) {
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId());
+ if (scheduler != null) {
+ scheduler.updateTvInputInfo(input);
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java b/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java
new file mode 100644
index 00000000..8a211f66
--- /dev/null
+++ b/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java
@@ -0,0 +1,562 @@
+/*
+ * 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.ApplicationSingletons;
+import com.android.tv.TvApplication;
+import com.android.tv.common.CollectionUtils;
+import com.android.tv.common.SharedPreferencesUtils;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Program;
+import com.android.tv.data.epg.EpgFetcher;
+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.SeasonEpisodeNumber;
+import com.android.tv.dvr.data.ScheduledRecording;
+import com.android.tv.dvr.data.SeriesInfo;
+import com.android.tv.dvr.data.SeriesRecording;
+import com.android.tv.dvr.provider.EpisodicProgramLoadTask;
+import com.android.tv.experiments.Experiments;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+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;
+
+/**
+ * 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();
+ ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
+ mDvrManager = appSingletons.getDvrManager();
+ mDataManager = (WritableDvrDataManager) appSingletons.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);
+ 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, new Comparator<Program>() {
+ @Override
+ public int compare(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 SeriesRecording mSeriesRecording;
+
+ FetchSeriesInfoTask(SeriesRecording seriesRecording) {
+ mSeriesRecording = seriesRecording;
+ }
+
+ @Override
+ protected SeriesInfo doInBackground(Void... voids) {
+ return EpgFetcher.createEpgReader(mContext)
+ .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());
+ }
+ }
+}