/* * 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. * *

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 END_TIME_COMPARATOR = new Comparator() { @Override public int compare(RecordingTask lhs, RecordingTask rhs) { return Long.compare(lhs.getEndTimeMs(), rhs.getEndTimeMs()); } }; /** * Compares ID in ascending order. */ public static final Comparator ID_COMPARATOR = new Comparator() { @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 PRIORITY_COMPARATOR = new Comparator() { @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); } } }