blob: ff37f3f00948b9b97e145da6a97fd3f2646a94dd [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.tv.dvr.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.TvSingletons;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.util.Clock;
import com.android.tv.common.util.CommonUtils;
import com.android.tv.data.api.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.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
@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 %s", 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) {
Log.i(TAG, "Recording Stopped: " + mScheduledRecording);
Log.i(TAG, "Recording Stopped: stored as " + recordedProgramUri);
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) {
Log.i(TAG, "Recording failed with code=" + reason + " for " + mScheduledRecording);
if (mRecordingSession == null) {
return;
}
switch (reason) {
case TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE:
Log.i(TAG, "Insufficient space to record " + mScheduledRecording);
mMainThreadHandler.post(
new Runnable() {
@Override
public void run() {
if (TvSingletons.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));
}
}
});
// fall 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() {
Log.i(TAG, "Start Recording: " + 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() {
Log.i(TAG, "Stop Recording: " + 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 "
+ CommonUtils.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);
}
}
}