/* * 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.usbtuner.tvinput; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.media.MediaDataSource; import android.media.tv.TvContract; import android.media.tv.TvInputManager; import android.net.Uri; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import android.support.annotation.IntDef; import android.support.annotation.Nullable; import android.util.Log; import android.widget.Toast; import com.google.android.exoplayer.util.Assertions; import com.android.tv.common.recording.RecordedProgram; import com.android.tv.common.recording.RecordingCapability; import com.android.usbtuner.DvbDeviceAccessor; import com.android.usbtuner.TunerHal; import com.android.usbtuner.UsbTunerDataSource; import com.android.usbtuner.data.PsipData; import com.android.usbtuner.data.TunerChannel; import com.android.usbtuner.exoplayer.Recorder; import com.android.usbtuner.exoplayer.cache.CacheManager; import com.android.usbtuner.exoplayer.cache.DvrStorageManager; import java.io.File; import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.List; import java.util.Locale; import java.util.Random; /** * Implements a DVR feature. */ public class TunerRecordingSessionWorker implements PlaybackCacheListener, EventDetector.EventListener, Recorder.RecordListener, Handler.Callback { private static String TAG = "TunerRecordingSessionWorker"; private static final boolean DEBUG = false; private static final String SORT_BY_TIME = TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS + ", " + TvContract.Programs.COLUMN_CHANNEL_ID + ", " + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS; private static final int MSG_CONNECT = 1; private static final int MSG_DISCONNECT = 2; private static final int MSG_START_RECORDING = 3; private static final int MSG_STOP_RECORDING = 4; private static final int MSG_RECORDING_RESULT = 5; private static final int MSG_DELETE_RECORDING = 6; private static final int MSG_RELEASE = 7; private RecordingCapability mCapabilities; public RecordingCapability getCapabilities() { return mCapabilities; } @IntDef({STATE_IDLE, STATE_CONNECTED, STATE_RECORDING}) @Retention(RetentionPolicy.SOURCE) public @interface DvrSessionState {} private static final int STATE_IDLE = 1; private static final int STATE_CONNECTED = 2; private static final int STATE_RECORDING = 3; private static final long CHANNEL_ID_NONE = -1; private final Context mContext; private final ChannelDataManager mChannelDataManager; private final Handler mHandler; private final Random mRandom = new Random(); private TunerHal mTunerHal; private UsbTunerDataSource mTunerSource; private TunerChannel mChannel; private File mStorageDir; private long mRecordStartTime; private long mRecordEndTime; private CacheManager mCacheManager; private Recorder mRecorder; private final TunerRecordingSession mSession; @DvrSessionState private int mSessionState = STATE_IDLE; private final String mInputId; public TunerRecordingSessionWorker(Context context, String inputId, ChannelDataManager dataManager, TunerRecordingSession session) { mRandom.setSeed(System.nanoTime()); mContext = context; HandlerThread handlerThread = new HandlerThread(TAG); handlerThread.start(); mHandler = new Handler(handlerThread.getLooper(), this); mChannelDataManager = dataManager; mChannelDataManager.checkDataVersion(context); mCapabilities = new DvbDeviceAccessor(context).getRecordingCapability(inputId); mInputId = inputId; if (DEBUG) Log.d(TAG, mCapabilities.toString()); mSession = session; } // PlaybackCacheListener @Override public void onCacheStartTimeChanged(long startTimeMs) { } @Override public void onCacheStateChanged(boolean available) { } @Override public void onDiskTooSlow() { } // EventDetector.EventListener @Override public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) { if (mChannel == null || mChannel.compareTo(channel) != 0) { return; } mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime); } @Override public void onEventDetected(TunerChannel channel, List items) { if (mChannel == null || mChannel.compareTo(channel) != 0) { return; } mChannelDataManager.notifyEventDetected(channel, items); } public void connect(Uri channelUri) { mHandler.removeCallbacksAndMessages(null); mHandler.obtainMessage(MSG_CONNECT, channelUri).sendToTarget(); } public void disconnect() { mHandler.sendEmptyMessage(MSG_DISCONNECT); } public void startRecording() { mHandler.sendEmptyMessage(MSG_START_RECORDING); } public void stopRecording() { mHandler.sendEmptyMessage(MSG_STOP_RECORDING); } public void notifyRecordingFinished(boolean success) { mHandler.obtainMessage(MSG_RECORDING_RESULT, success).sendToTarget(); } public void deleteRecording(Uri mediaUri) { mHandler.obtainMessage(MSG_DELETE_RECORDING, mediaUri).sendToTarget(); } public void release() { mHandler.removeCallbacksAndMessages(null); mHandler.sendEmptyMessage(MSG_RELEASE); } @Override public boolean handleMessage(Message msg) { // TODO: Add RecordStopped status switch (msg.what) { case MSG_CONNECT: { Uri channelUri = (Uri) msg.obj; if (onConnect(channelUri)) { mSession.onTuned(channelUri); } else { Log.w(TAG, "Recording session connect failed"); mSession.onConnectFailed(); } return true; } case MSG_START_RECORDING: { if(onStartRecording()) { Toast.makeText(mContext, "USB TV tuner: Recording started", Toast.LENGTH_SHORT).show(); } else { mSession.onRecordUnexpectedlyStopped(TvInputManager.RECORDING_ERROR_UNKNOWN); } return true; } case MSG_DISCONNECT: { return true; } case MSG_STOP_RECORDING: { onStopRecording(); new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { Toast.makeText(mContext, "USB TV tuner: Recording stopped", Toast.LENGTH_SHORT).show(); } }); return true; } case MSG_RECORDING_RESULT: { onRecordingResult((Boolean) msg.obj); return true; } case MSG_DELETE_RECORDING: { Uri toDelete = (Uri) msg.obj; onDeleteRecording(toDelete); return true; } case MSG_RELEASE: { onRelease(); return true; } } return false; } @Nullable private TunerChannel getChannel(Uri channelUri) { if (channelUri == null) { return null; } long channelId; try { channelId = ContentUris.parseId(channelUri); } catch (UnsupportedOperationException | NumberFormatException e) { channelId = CHANNEL_ID_NONE; } return (channelId == CHANNEL_ID_NONE) ? null : mChannelDataManager.getChannel(channelId); } private String getStorageKey() { long prefix = System.currentTimeMillis(); int suffix = mRandom.nextInt(); return String.format(Locale.ENGLISH, "%016x_%016x", prefix, suffix); } private File getMediaDir(String storageKey) { return new File(mContext.getCacheDir().getAbsolutePath() + "/recording/" + storageKey); } private File getMediaDir(Uri mediaUri) { String mediaPath = mediaUri.getPath(); if (mediaPath == null || mediaPath.length() == 0) { return null; } return new File(mContext.getCacheDir().getAbsolutePath() + "/recording" + mediaUri.getPath()); } private void reset() { if (mRecorder != null) { mRecorder.release(); mRecorder = null; } if (mCacheManager != null) { mCacheManager.close(); mCacheManager = null; } if (mTunerSource != null) { mTunerSource.stopStream(); mTunerSource = null; } if (mTunerHal != null) { try { mTunerHal.close(); } catch (Exception ex) { Log.e(TAG, "Error on closing tuner HAL.", ex); } mTunerHal = null; } mSessionState = STATE_IDLE; } private void resetRecorder() { Assertions.checkArgument(mSessionState != STATE_IDLE); if (mRecorder != null) { mRecorder.release(); mRecorder = null; } if (mCacheManager != null) { mCacheManager.close(); mCacheManager = null; } if (mTunerSource != null) { mTunerSource.stopStream(); mTunerSource = null; } mSessionState = STATE_CONNECTED; } private boolean onConnect(Uri channelUri) { if (mSessionState == STATE_RECORDING) { return false; } mChannel = getChannel(channelUri); if (mChannel == null) { Log.w(TAG, "Failed to start recording. Couldn't find the channel for " + mChannel); return false; } if (mSessionState == STATE_CONNECTED) { return true; } mTunerHal = TunerHal.createInstance(mContext); if (mTunerHal == null) { Log.w(TAG, "Failed to start recording. Couldn't open a DVB device"); reset(); return false; } mSessionState = STATE_CONNECTED; return true; } private boolean onStartRecording() { if (mSessionState != STATE_CONNECTED) { return false; } mStorageDir = getMediaDir(getStorageKey()); mTunerSource = new UsbTunerDataSource(mTunerHal, this); if (!mTunerSource.tuneToChannel(mChannel)) { Log.w(TAG, "Failed to start recording. Couldn't tune to the channel for " + mChannel.toString()); resetRecorder(); return false; } mCacheManager = new CacheManager(new DvrStorageManager(mStorageDir, true)); mTunerSource.startStream(); mRecordStartTime = System.currentTimeMillis(); mRecorder = new Recorder((MediaDataSource) mTunerSource, mCacheManager, this, this); try { mRecorder.prepare(); } catch (IOException e) { Log.w(TAG, "Failed to start recording. Couldn't prepare a extractor"); resetRecorder(); return false; } mSessionState = STATE_RECORDING; return true; } private void onStopRecording() { if (mSessionState != STATE_RECORDING) { return; } // Do not change session status. if (mRecorder != null) { mRecorder.release(); mRecordEndTime = System.currentTimeMillis(); mRecorder = null; } } private static class Program { private long mChannelId; private String mTitle; private String mEpisodeTitle; private int mSeasonNumber; private int mEpisodeNumber; private String mDescription; private String mPosterArtUri; private String mThumbnailUri; private String mCanonicalGenres; private String mContentRatings; private long mStartTimeUtcMillis; private long mEndTimeUtcMillis; private long mVideoWidth; private long mVideoHeight; private static final String[] PROJECTION = { TvContract.Programs.COLUMN_CHANNEL_ID, TvContract.Programs.COLUMN_TITLE, TvContract.Programs.COLUMN_EPISODE_TITLE, TvContract.Programs.COLUMN_SEASON_NUMBER, TvContract.Programs.COLUMN_EPISODE_NUMBER, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, TvContract.Programs.COLUMN_POSTER_ART_URI, TvContract.Programs.COLUMN_THUMBNAIL_URI, TvContract.Programs.COLUMN_CANONICAL_GENRE, TvContract.Programs.COLUMN_CONTENT_RATING, TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, TvContract.Programs.COLUMN_VIDEO_WIDTH, TvContract.Programs.COLUMN_VIDEO_HEIGHT }; public Program(Cursor cursor) { int index = 0; mChannelId = cursor.getLong(index++); mTitle = cursor.getString(index++); mEpisodeTitle = cursor.getString(index++); mSeasonNumber = cursor.getInt(index++); mEpisodeNumber = cursor.getInt(index++); mDescription = cursor.getString(index++); mPosterArtUri = cursor.getString(index++); mThumbnailUri = cursor.getString(index++); mCanonicalGenres = cursor.getString(index++); mContentRatings = cursor.getString(index++); mStartTimeUtcMillis = cursor.getLong(index++); mEndTimeUtcMillis = cursor.getLong(index++); mVideoWidth = cursor.getLong(index++); mVideoHeight = cursor.getLong(index++); } public Program(long channelId) { mChannelId = channelId; mTitle = "Unknown"; mEpisodeTitle = ""; mSeasonNumber = 0; mEpisodeNumber = 0; mDescription = "Unknown"; mPosterArtUri = null; mThumbnailUri = null; mCanonicalGenres = null; mContentRatings = null; mStartTimeUtcMillis = 0; mEndTimeUtcMillis = 0; mVideoWidth = 0; mVideoHeight = 0; } public static Program onQuery(Cursor c) { Program program = null; if (c != null && c.moveToNext()) { program = new Program(c); } return program; } public ContentValues buildValues() { ContentValues values = new ContentValues(); values.put(PROJECTION[0], mChannelId); values.put(PROJECTION[1], mTitle); values.put(PROJECTION[2], mEpisodeTitle); values.put(PROJECTION[3], mSeasonNumber); values.put(PROJECTION[4], mEpisodeNumber); values.put(PROJECTION[5], mDescription); values.put(PROJECTION[6], mPosterArtUri); values.put(PROJECTION[7], mThumbnailUri); values.put(PROJECTION[8], mCanonicalGenres); values.put(PROJECTION[9], mContentRatings); values.put(PROJECTION[10], mStartTimeUtcMillis); values.put(PROJECTION[11], mEndTimeUtcMillis); values.put(PROJECTION[12], mVideoWidth); values.put(PROJECTION[13], mVideoHeight); return values; } } private Program getRecordedProgram() { ContentResolver resolver = mContext.getContentResolver(); long avg = mRecordStartTime / 2 + mRecordEndTime / 2; Uri programUri = TvContract.buildProgramsUriForChannel(mChannel.getChannelId(), avg, avg); try (Cursor c = resolver.query(programUri, Program.PROJECTION, null, null, SORT_BY_TIME)) { if (c != null) { Program result = Program.onQuery(c); if (DEBUG) { Log.v(TAG, "Finished query for " + this); } return result; } else { if (c == null) { Log.e(TAG, "Unknown query error for " + this); } else { if (DEBUG) { Log.d(TAG, "Canceled query for " + this); } } return null; } } } private Uri insertRecordedProgram(Program program, long channelId, String storageUri, long totalBytes, long startTime, long endTime) { RecordedProgram recordedProgram = RecordedProgram.builder() .setInputId(mInputId) .setChannelId(channelId) .setDataUri(storageUri) .setDurationMillis(endTime - startTime) .setDataBytes(totalBytes) .build(); Uri uri = mContext.getContentResolver().insert(TvContract.RecordedPrograms.CONTENT_URI, RecordedProgram.toValues(recordedProgram)); return uri; } private boolean onRecordingResult(boolean success) { if (mSessionState == STATE_RECORDING && success) { Uri uri = insertRecordedProgram(getRecordedProgram(), mChannel.getChannelId(), mStorageDir.toURI().toString(), 1024 * 1024, mRecordStartTime, mRecordEndTime); if (uri != null) { mSession.onRecordFinished(uri); } resetRecorder(); return true; } if (mSessionState == STATE_RECORDING) { mSession.onRecordUnexpectedlyStopped(TvInputManager.RECORDING_ERROR_UNKNOWN); Log.w(TAG, "Recording failed: " + mChannel == null ? "" : mChannel.toString()); resetRecorder(); } else { Log.e(TAG, "Recording session status abnormal"); reset(); } return false; } private void onDeleteRecording(Uri mediaUri) { // TODO: notify the deletion result to LiveChannels File mediaDir = getMediaDir(mediaUri); if (mediaDir == null) { return; } for(File file: mediaDir.listFiles()) { file.delete(); } mediaDir.delete(); } private void onRelease() { // Current recording will be canceled. reset(); mHandler.getLooper().quitSafely(); // TODO: Remove failed recording files. } }