1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.usbtuner.tvinput;
18
19import android.content.ContentResolver;
20import android.content.ContentUris;
21import android.content.ContentValues;
22import android.content.Context;
23import android.database.Cursor;
24import android.media.MediaDataSource;
25import android.media.tv.TvContract;
26import android.media.tv.TvInputManager;
27import android.net.Uri;
28import android.os.Handler;
29import android.os.HandlerThread;
30import android.os.Looper;
31import android.os.Message;
32import android.support.annotation.IntDef;
33import android.support.annotation.Nullable;
34import android.util.Log;
35import android.widget.Toast;
36
37import com.google.android.exoplayer.util.Assertions;
38import com.android.tv.common.recording.RecordedProgram;
39import com.android.tv.common.recording.RecordingCapability;
40import com.android.usbtuner.DvbDeviceAccessor;
41import com.android.usbtuner.TunerHal;
42import com.android.usbtuner.UsbTunerDataSource;
43import com.android.usbtuner.data.PsipData;
44import com.android.usbtuner.data.TunerChannel;
45import com.android.usbtuner.exoplayer.Recorder;
46import com.android.usbtuner.exoplayer.cache.CacheManager;
47import com.android.usbtuner.exoplayer.cache.DvrStorageManager;
48
49import java.io.File;
50import java.io.IOException;
51import java.lang.annotation.Retention;
52import java.lang.annotation.RetentionPolicy;
53import java.util.List;
54import java.util.Locale;
55import java.util.Random;
56
57/**
58 * Implements a DVR feature.
59 */
60public class TunerRecordingSessionWorker implements PlaybackCacheListener,
61        EventDetector.EventListener, Recorder.RecordListener,
62        Handler.Callback {
63    private static String TAG = "TunerRecordingSessionWorker";
64    private static final boolean DEBUG = false;
65
66    private static final String SORT_BY_TIME = TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS
67            + ", " + TvContract.Programs.COLUMN_CHANNEL_ID + ", "
68            + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS;
69    private static final int MSG_CONNECT = 1;
70    private static final int MSG_DISCONNECT = 2;
71    private static final int MSG_START_RECORDING = 3;
72    private static final int MSG_STOP_RECORDING = 4;
73    private static final int MSG_RECORDING_RESULT = 5;
74    private static final int MSG_DELETE_RECORDING = 6;
75    private static final int MSG_RELEASE = 7;
76    private RecordingCapability mCapabilities;
77
78    public RecordingCapability getCapabilities() {
79        return mCapabilities;
80    }
81
82    @IntDef({STATE_IDLE, STATE_CONNECTED, STATE_RECORDING})
83    @Retention(RetentionPolicy.SOURCE)
84    public @interface DvrSessionState {}
85    private static final int STATE_IDLE = 1;
86    private static final int STATE_CONNECTED = 2;
87    private static final int STATE_RECORDING = 3;
88
89    private static final long CHANNEL_ID_NONE = -1;
90
91    private final Context mContext;
92    private final ChannelDataManager mChannelDataManager;
93    private final Handler mHandler;
94    private final Random mRandom = new Random();
95
96    private TunerHal mTunerHal;
97    private UsbTunerDataSource mTunerSource;
98    private TunerChannel mChannel;
99    private File mStorageDir;
100    private long mRecordStartTime;
101    private long mRecordEndTime;
102    private CacheManager mCacheManager;
103    private Recorder mRecorder;
104    private final TunerRecordingSession mSession;
105    @DvrSessionState private int mSessionState = STATE_IDLE;
106    private final String mInputId;
107
108    public TunerRecordingSessionWorker(Context context, String inputId,
109            ChannelDataManager dataManager, TunerRecordingSession session) {
110        mRandom.setSeed(System.nanoTime());
111        mContext = context;
112        HandlerThread handlerThread = new HandlerThread(TAG);
113        handlerThread.start();
114        mHandler = new Handler(handlerThread.getLooper(), this);
115        mChannelDataManager = dataManager;
116        mChannelDataManager.checkDataVersion(context);
117        mCapabilities = new DvbDeviceAccessor(context).getRecordingCapability(inputId);
118        mInputId = inputId;
119        if (DEBUG) Log.d(TAG, mCapabilities.toString());
120        mSession = session;
121    }
122
123    // PlaybackCacheListener
124    @Override
125    public void onCacheStartTimeChanged(long startTimeMs) {
126    }
127
128    @Override
129    public void onCacheStateChanged(boolean available) {
130    }
131
132    @Override
133    public void onDiskTooSlow() {
134    }
135
136    // EventDetector.EventListener
137    @Override
138    public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) {
139        if (mChannel == null || mChannel.compareTo(channel) != 0) {
140            return;
141        }
142        mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime);
143    }
144
145    @Override
146    public void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items) {
147        if (mChannel == null || mChannel.compareTo(channel) != 0) {
148            return;
149        }
150        mChannelDataManager.notifyEventDetected(channel, items);
151    }
152
153    public void connect(Uri channelUri) {
154        mHandler.removeCallbacksAndMessages(null);
155        mHandler.obtainMessage(MSG_CONNECT, channelUri).sendToTarget();
156    }
157
158    public void disconnect() {
159        mHandler.sendEmptyMessage(MSG_DISCONNECT);
160    }
161
162    public void startRecording() {
163        mHandler.sendEmptyMessage(MSG_START_RECORDING);
164    }
165
166    public void stopRecording() {
167        mHandler.sendEmptyMessage(MSG_STOP_RECORDING);
168    }
169
170    public void notifyRecordingFinished(boolean success) {
171        mHandler.obtainMessage(MSG_RECORDING_RESULT, success).sendToTarget();
172    }
173
174    public void deleteRecording(Uri mediaUri) {
175        mHandler.obtainMessage(MSG_DELETE_RECORDING, mediaUri).sendToTarget();
176    }
177
178    public void release() {
179        mHandler.removeCallbacksAndMessages(null);
180        mHandler.sendEmptyMessage(MSG_RELEASE);
181    }
182
183    @Override
184    public boolean handleMessage(Message msg) {
185        // TODO: Add RecordStopped status
186        switch (msg.what) {
187            case MSG_CONNECT: {
188                Uri channelUri = (Uri) msg.obj;
189                if (onConnect(channelUri)) {
190                    mSession.onTuned(channelUri);
191                } else {
192                    Log.w(TAG, "Recording session connect failed");
193                    mSession.onConnectFailed();
194                }
195                return true;
196            }
197            case MSG_START_RECORDING: {
198                if(onStartRecording()) {
199                    Toast.makeText(mContext, "USB TV tuner: Recording started",
200                            Toast.LENGTH_SHORT).show();
201                }
202                else {
203                    mSession.onRecordUnexpectedlyStopped(TvInputManager.RECORDING_ERROR_UNKNOWN);
204                }
205                return true;
206            }
207            case MSG_DISCONNECT: {
208                return true;
209            }
210            case MSG_STOP_RECORDING: {
211                onStopRecording();
212                new Handler(Looper.getMainLooper()).post(new Runnable() {
213                    @Override
214                    public void run() {
215                        Toast.makeText(mContext, "USB TV tuner: Recording stopped",
216                                Toast.LENGTH_SHORT).show();
217                    }
218                });
219                return true;
220            }
221            case MSG_RECORDING_RESULT: {
222                onRecordingResult((Boolean) msg.obj);
223                return true;
224            }
225            case MSG_DELETE_RECORDING: {
226                Uri toDelete = (Uri) msg.obj;
227                onDeleteRecording(toDelete);
228                return true;
229            }
230            case MSG_RELEASE: {
231                onRelease();
232                return true;
233            }
234        }
235        return false;
236    }
237
238    @Nullable
239    private TunerChannel getChannel(Uri channelUri) {
240        if (channelUri == null) {
241            return null;
242        }
243        long channelId;
244        try {
245            channelId = ContentUris.parseId(channelUri);
246        } catch (UnsupportedOperationException | NumberFormatException e) {
247            channelId = CHANNEL_ID_NONE;
248        }
249        return (channelId == CHANNEL_ID_NONE) ? null : mChannelDataManager.getChannel(channelId);
250    }
251
252    private String getStorageKey() {
253        long prefix = System.currentTimeMillis();
254        int suffix = mRandom.nextInt();
255        return String.format(Locale.ENGLISH, "%016x_%016x", prefix, suffix);
256    }
257
258    private File getMediaDir(String storageKey) {
259        return new File(mContext.getCacheDir().getAbsolutePath() + "/recording/" + storageKey);
260    }
261
262    private File getMediaDir(Uri mediaUri) {
263        String mediaPath = mediaUri.getPath();
264        if (mediaPath == null || mediaPath.length() == 0) {
265            return null;
266        }
267        return new File(mContext.getCacheDir().getAbsolutePath() + "/recording" +
268                mediaUri.getPath());
269    }
270
271    private void reset() {
272        if (mRecorder != null) {
273            mRecorder.release();
274            mRecorder = null;
275        }
276        if (mCacheManager != null) {
277            mCacheManager.close();
278            mCacheManager = null;
279        }
280        if (mTunerSource != null) {
281            mTunerSource.stopStream();
282            mTunerSource = null;
283        }
284        if (mTunerHal != null) {
285            try {
286                mTunerHal.close();
287            } catch (Exception ex) {
288                Log.e(TAG, "Error on closing tuner HAL.", ex);
289            }
290            mTunerHal = null;
291        }
292        mSessionState = STATE_IDLE;
293    }
294
295    private void resetRecorder() {
296        Assertions.checkArgument(mSessionState != STATE_IDLE);
297        if (mRecorder != null) {
298            mRecorder.release();
299            mRecorder = null;
300        }
301        if (mCacheManager != null) {
302            mCacheManager.close();
303            mCacheManager = null;
304        }
305        if (mTunerSource != null) {
306            mTunerSource.stopStream();
307            mTunerSource = null;
308        }
309        mSessionState = STATE_CONNECTED;
310    }
311
312    private boolean onConnect(Uri channelUri) {
313        if (mSessionState == STATE_RECORDING) {
314            return false;
315        }
316        mChannel = getChannel(channelUri);
317        if (mChannel == null) {
318            Log.w(TAG, "Failed to start recording. Couldn't find the channel for " + mChannel);
319            return false;
320        }
321        if (mSessionState == STATE_CONNECTED) {
322            return true;
323        }
324        mTunerHal = TunerHal.createInstance(mContext);
325        if (mTunerHal == null) {
326            Log.w(TAG, "Failed to start recording. Couldn't open a DVB device");
327            reset();
328            return false;
329        }
330        mSessionState = STATE_CONNECTED;
331        return true;
332    }
333
334    private boolean onStartRecording() {
335        if (mSessionState != STATE_CONNECTED) {
336            return false;
337        }
338        mStorageDir = getMediaDir(getStorageKey());
339        mTunerSource = new UsbTunerDataSource(mTunerHal, this);
340        if (!mTunerSource.tuneToChannel(mChannel)) {
341            Log.w(TAG, "Failed to start recording. Couldn't tune to the channel for " +
342                    mChannel.toString());
343            resetRecorder();
344            return false;
345        }
346        mCacheManager = new CacheManager(new DvrStorageManager(mStorageDir, true));
347        mTunerSource.startStream();
348        mRecordStartTime = System.currentTimeMillis();
349        mRecorder = new Recorder((MediaDataSource) mTunerSource,
350                mCacheManager, this, this);
351        try {
352            mRecorder.prepare();
353        } catch (IOException e) {
354            Log.w(TAG, "Failed to start recording. Couldn't prepare a extractor");
355            resetRecorder();
356            return false;
357        }
358        mSessionState = STATE_RECORDING;
359        return true;
360    }
361
362    private void onStopRecording() {
363        if (mSessionState != STATE_RECORDING) {
364            return;
365        }
366        // Do not change session status.
367        if (mRecorder != null) {
368            mRecorder.release();
369            mRecordEndTime = System.currentTimeMillis();
370            mRecorder = null;
371        }
372    }
373
374    private static class Program {
375        private long mChannelId;
376        private String mTitle;
377        private String mEpisodeTitle;
378        private int mSeasonNumber;
379        private int mEpisodeNumber;
380        private String mDescription;
381        private String mPosterArtUri;
382        private String mThumbnailUri;
383        private String mCanonicalGenres;
384        private String mContentRatings;
385        private long mStartTimeUtcMillis;
386        private long mEndTimeUtcMillis;
387        private long mVideoWidth;
388        private long mVideoHeight;
389
390        private static final String[] PROJECTION = {
391                TvContract.Programs.COLUMN_CHANNEL_ID,
392                TvContract.Programs.COLUMN_TITLE,
393                TvContract.Programs.COLUMN_EPISODE_TITLE,
394                TvContract.Programs.COLUMN_SEASON_NUMBER,
395                TvContract.Programs.COLUMN_EPISODE_NUMBER,
396                TvContract.Programs.COLUMN_SHORT_DESCRIPTION,
397                TvContract.Programs.COLUMN_POSTER_ART_URI,
398                TvContract.Programs.COLUMN_THUMBNAIL_URI,
399                TvContract.Programs.COLUMN_CANONICAL_GENRE,
400                TvContract.Programs.COLUMN_CONTENT_RATING,
401                TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
402                TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS,
403                TvContract.Programs.COLUMN_VIDEO_WIDTH,
404                TvContract.Programs.COLUMN_VIDEO_HEIGHT
405        };
406
407        public Program(Cursor cursor) {
408            int index = 0;
409            mChannelId = cursor.getLong(index++);
410            mTitle = cursor.getString(index++);
411            mEpisodeTitle = cursor.getString(index++);
412            mSeasonNumber = cursor.getInt(index++);
413            mEpisodeNumber = cursor.getInt(index++);
414            mDescription = cursor.getString(index++);
415            mPosterArtUri = cursor.getString(index++);
416            mThumbnailUri = cursor.getString(index++);
417            mCanonicalGenres = cursor.getString(index++);
418            mContentRatings = cursor.getString(index++);
419            mStartTimeUtcMillis = cursor.getLong(index++);
420            mEndTimeUtcMillis = cursor.getLong(index++);
421            mVideoWidth = cursor.getLong(index++);
422            mVideoHeight = cursor.getLong(index++);
423        }
424
425        public Program(long channelId) {
426            mChannelId = channelId;
427            mTitle = "Unknown";
428            mEpisodeTitle = "";
429            mSeasonNumber = 0;
430            mEpisodeNumber = 0;
431            mDescription = "Unknown";
432            mPosterArtUri = null;
433            mThumbnailUri = null;
434            mCanonicalGenres = null;
435            mContentRatings = null;
436            mStartTimeUtcMillis = 0;
437            mEndTimeUtcMillis = 0;
438            mVideoWidth = 0;
439            mVideoHeight = 0;
440        }
441
442        public static Program onQuery(Cursor c) {
443            Program program = null;
444            if (c != null && c.moveToNext()) {
445                program = new Program(c);
446            }
447            return program;
448        }
449
450        public ContentValues buildValues() {
451            ContentValues values = new ContentValues();
452            values.put(PROJECTION[0], mChannelId);
453            values.put(PROJECTION[1], mTitle);
454            values.put(PROJECTION[2], mEpisodeTitle);
455            values.put(PROJECTION[3], mSeasonNumber);
456            values.put(PROJECTION[4], mEpisodeNumber);
457            values.put(PROJECTION[5], mDescription);
458            values.put(PROJECTION[6], mPosterArtUri);
459            values.put(PROJECTION[7], mThumbnailUri);
460            values.put(PROJECTION[8], mCanonicalGenres);
461            values.put(PROJECTION[9], mContentRatings);
462            values.put(PROJECTION[10], mStartTimeUtcMillis);
463            values.put(PROJECTION[11], mEndTimeUtcMillis);
464            values.put(PROJECTION[12], mVideoWidth);
465            values.put(PROJECTION[13], mVideoHeight);
466            return values;
467        }
468    }
469
470    private Program getRecordedProgram() {
471        ContentResolver resolver = mContext.getContentResolver();
472        long avg = mRecordStartTime / 2 + mRecordEndTime / 2;
473        Uri programUri = TvContract.buildProgramsUriForChannel(mChannel.getChannelId(), avg, avg);
474        try (Cursor c = resolver.query(programUri, Program.PROJECTION, null, null, SORT_BY_TIME)) {
475            if (c != null) {
476                Program result = Program.onQuery(c);
477                if (DEBUG) {
478                    Log.v(TAG, "Finished query for " + this);
479                }
480                return result;
481            } else {
482                if (c == null) {
483                    Log.e(TAG, "Unknown query error for " + this);
484                } else {
485                    if (DEBUG) {
486                        Log.d(TAG, "Canceled query for " + this);
487                    }
488                }
489                return null;
490            }
491        }
492    }
493
494    private Uri insertRecordedProgram(Program program, long channelId, String storageUri,
495            long totalBytes, long startTime, long endTime) {
496        RecordedProgram recordedProgram = RecordedProgram.builder()
497                .setInputId(mInputId)
498                .setChannelId(channelId)
499                .setDataUri(storageUri)
500                .setDurationMillis(endTime - startTime)
501                .setDataBytes(totalBytes)
502                .build();
503        Uri uri = mContext.getContentResolver().insert(TvContract.RecordedPrograms.CONTENT_URI,
504                RecordedProgram.toValues(recordedProgram));
505        return uri;
506    }
507
508    private boolean onRecordingResult(boolean success) {
509        if (mSessionState == STATE_RECORDING && success) {
510            Uri uri = insertRecordedProgram(getRecordedProgram(), mChannel.getChannelId(),
511                    mStorageDir.toURI().toString(), 1024 * 1024,
512                    mRecordStartTime, mRecordEndTime);
513            if (uri != null) {
514                mSession.onRecordFinished(uri);
515            }
516            resetRecorder();
517            return true;
518        }
519
520        if (mSessionState == STATE_RECORDING) {
521            mSession.onRecordUnexpectedlyStopped(TvInputManager.RECORDING_ERROR_UNKNOWN);
522            Log.w(TAG, "Recording failed: " + mChannel == null ? "" : mChannel.toString());
523            resetRecorder();
524        } else {
525            Log.e(TAG, "Recording session status abnormal");
526            reset();
527        }
528        return false;
529    }
530
531    private void onDeleteRecording(Uri mediaUri) {
532        // TODO: notify the deletion result to LiveChannels
533        File mediaDir = getMediaDir(mediaUri);
534        if (mediaDir == null) {
535            return;
536        }
537        for(File file: mediaDir.listFiles()) {
538            file.delete();
539        }
540        mediaDir.delete();
541    }
542
543    private void onRelease() {
544        // Current recording will be canceled.
545        reset();
546        mHandler.getLooper().quitSafely();
547        // TODO: Remove failed recording files.
548    }
549}
550