diff options
Diffstat (limited to 'src/com/android/tv/dvr/recorder')
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()); + } + } +} |