aboutsummaryrefslogtreecommitdiff
path: root/src/com/android/tv/tuner/tvinput
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/tv/tuner/tvinput')
-rw-r--r--src/com/android/tv/tuner/tvinput/ChannelDataManager.java706
-rw-r--r--src/com/android/tv/tuner/tvinput/EventDetector.java261
-rw-r--r--src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java210
-rw-r--r--src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java42
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerDebug.java150
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerRecordingSession.java104
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java594
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerSession.java312
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerSessionWorker.java1583
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java166
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerTvInputService.java146
11 files changed, 4274 insertions, 0 deletions
diff --git a/src/com/android/tv/tuner/tvinput/ChannelDataManager.java b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java
new file mode 100644
index 00000000..a16bc522
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java
@@ -0,0 +1,706 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.content.ComponentName;
+import android.content.ContentProviderOperation;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.OperationApplicationException;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.RemoteException;
+import android.support.annotation.Nullable;
+import android.text.format.DateUtils;
+import android.util.Log;
+
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.util.ConvertUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Manages the channel info and EPG data through {@link TvInputManager}.
+ */
+public class ChannelDataManager implements Handler.Callback {
+ private static final String TAG = "ChannelDataManager";
+
+ private static final String[] ALL_PROGRAMS_SELECTION_ARGS = new String[] {
+ TvContract.Programs._ID,
+ TvContract.Programs.COLUMN_TITLE,
+ TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
+ TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS,
+ TvContract.Programs.COLUMN_CONTENT_RATING,
+ TvContract.Programs.COLUMN_BROADCAST_GENRE,
+ TvContract.Programs.COLUMN_CANONICAL_GENRE,
+ TvContract.Programs.COLUMN_SHORT_DESCRIPTION,
+ TvContract.Programs.COLUMN_VERSION_NUMBER };
+ private static final String[] CHANNEL_DATA_SELECTION_ARGS = new String[] {
+ TvContract.Channels._ID,
+ TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA,
+ TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1};
+
+ private static final int MSG_HANDLE_EVENTS = 1;
+ private static final int MSG_HANDLE_CHANNEL = 2;
+ private static final int MSG_BUILD_CHANNEL_MAP = 3;
+ private static final int MSG_REQUEST_PROGRAMS = 4;
+ private static final int MSG_CLEAR_CHANNELS = 6;
+ private static final int MSG_CHECK_VERSION = 7;
+
+ // Throttle the batch operations to avoid TransactionTooLargeException.
+ private static final int BATCH_OPERATION_COUNT = 100;
+ // At most 16 days of program information is delivered through an EIT,
+ // according to the Chapter 6.4 of ATSC Recommended Practice A/69.
+ private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(16);
+
+ /**
+ * A version number to enforce consistency of the channel data.
+ *
+ * WARNING: If a change in the database serialization lead to breaking the backward
+ * compatibility, you must increment this value so that the old data are purged,
+ * and the user is requested to perform the auto-scan again to generate the new data set.
+ */
+ private static final int VERSION = 6;
+
+ private final Context mContext;
+ private final String mInputId;
+ private ProgramInfoListener mListener;
+ private ChannelScanListener mChannelScanListener;
+ private Handler mChannelScanHandler;
+ private final HandlerThread mHandlerThread;
+ private final Handler mHandler;
+ private final ConcurrentHashMap<Long, TunerChannel> mTunerChannelMap;
+ private final ConcurrentSkipListMap<TunerChannel, Long> mTunerChannelIdMap;
+ private final Uri mChannelsUri;
+
+ // Used for scanning
+ private final ConcurrentSkipListSet<TunerChannel> mScannedChannels;
+ private final ConcurrentSkipListSet<TunerChannel> mPreviousScannedChannels;
+ private final AtomicBoolean mIsScanning;
+ private final AtomicBoolean scanCompleted = new AtomicBoolean();
+
+ public interface ProgramInfoListener {
+
+ /**
+ * Invoked when a request for getting programs of a channel has been processed and passes
+ * the requested channel and the programs retrieved from database to the listener.
+ */
+ void onRequestProgramsResponse(TunerChannel channel, List<EitItem> programs);
+
+ /**
+ * Invoked when programs of a channel have been arrived and passes the arrived channel and
+ * programs to the listener.
+ */
+ void onProgramsArrived(TunerChannel channel, List<EitItem> programs);
+
+ /**
+ * Invoked when a channel has been arrived and passes the arrived channel to the listener.
+ */
+ void onChannelArrived(TunerChannel channel);
+
+ /**
+ * Invoked when the database schema has been changed and the old-format channels have been
+ * deleted. A receiver should notify to a user that re-scanning channels is necessary.
+ */
+ void onRescanNeeded();
+ }
+
+ public interface ChannelScanListener {
+ /**
+ * Invoked when all pending channels have been handled.
+ */
+ void onChannelHandlingDone();
+ }
+
+ public ChannelDataManager(Context context) {
+ mContext = context;
+ mInputId = TvContract.buildInputId(new ComponentName(mContext.getPackageName(),
+ TunerTvInputService.class.getName()));
+ mChannelsUri = TvContract.buildChannelsUriForInput(mInputId);
+ mTunerChannelMap = new ConcurrentHashMap<>();
+ mTunerChannelIdMap = new ConcurrentSkipListMap<>();
+ mHandlerThread = new HandlerThread("TvInputServiceBackgroundThread");
+ mHandlerThread.start();
+ mHandler = new Handler(mHandlerThread.getLooper(), this);
+ mIsScanning = new AtomicBoolean();
+ mScannedChannels = new ConcurrentSkipListSet<>();
+ mPreviousScannedChannels = new ConcurrentSkipListSet<>();
+ }
+
+ // Public methods
+ public void checkDataVersion(Context context) {
+ int version = TunerPreferences.getChannelDataVersion(context);
+ Log.d(TAG, "ChannelDataManager.VERSION=" + VERSION + " (current=" + version + ")");
+ if (version == VERSION) {
+ // Everything is awesome. Return and continue.
+ return;
+ }
+ setCurrentVersion(context);
+
+ if (version == TunerPreferences.CHANNEL_DATA_VERSION_NOT_SET) {
+ mHandler.sendEmptyMessage(MSG_CHECK_VERSION);
+ } else {
+ // The stored channel data seem outdated. Delete them all.
+ mHandler.sendEmptyMessage(MSG_CLEAR_CHANNELS);
+ }
+ }
+
+ public void setCurrentVersion(Context context) {
+ TunerPreferences.setChannelDataVersion(context, VERSION);
+ }
+
+ public void setListener(ProgramInfoListener listener) {
+ mListener = listener;
+ }
+
+ public void setChannelScanListener(ChannelScanListener listener, Handler handler) {
+ mChannelScanListener = listener;
+ mChannelScanHandler = handler;
+ }
+
+ public void release() {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandlerThread.quitSafely();
+ }
+
+ public void releaseSafely() {
+ mHandlerThread.quitSafely();
+ }
+
+ public TunerChannel getChannel(long channelId) {
+ TunerChannel channel = mTunerChannelMap.get(channelId);
+ if (channel != null) {
+ return channel;
+ }
+ mHandler.sendEmptyMessage(MSG_BUILD_CHANNEL_MAP);
+ byte[] data = null;
+ try (Cursor cursor = mContext.getContentResolver().query(TvContract.buildChannelUri(
+ channelId), CHANNEL_DATA_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ data = cursor.getBlob(1);
+ }
+ }
+ if (data == null) {
+ return null;
+ }
+ channel = TunerChannel.parseFrom(data);
+ if (channel == null) {
+ return null;
+ }
+ channel.setChannelId(channelId);
+ return channel;
+ }
+
+ public void requestProgramsData(TunerChannel channel) {
+ mHandler.removeMessages(MSG_REQUEST_PROGRAMS);
+ mHandler.obtainMessage(MSG_REQUEST_PROGRAMS, channel).sendToTarget();
+ }
+
+ public void notifyEventDetected(TunerChannel channel, List<EitItem> items) {
+ mHandler.obtainMessage(MSG_HANDLE_EVENTS, new ChannelEvent(channel, items)).sendToTarget();
+ }
+
+ public void notifyChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) {
+ if (mIsScanning.get()) {
+ // During scanning, channels should be handle first to improve scan time.
+ // EIT items can be handled in background after channel scan.
+ mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(MSG_HANDLE_CHANNEL, channel));
+ } else {
+ mHandler.obtainMessage(MSG_HANDLE_CHANNEL, channel).sendToTarget();
+ }
+ }
+
+ // For scanning process
+ /**
+ * Invoked when starting a scanning mode. This method gets the previous channels to detect the
+ * obsolete channels after scanning and initializes the variables used for scanning.
+ */
+ public void notifyScanStarted() {
+ mScannedChannels.clear();
+ mPreviousScannedChannels.clear();
+ try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri,
+ CHANNEL_DATA_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ long channelId = cursor.getLong(0);
+ byte[] data = cursor.getBlob(1);
+ TunerChannel channel = TunerChannel.parseFrom(data);
+ if (channel != null) {
+ channel.setChannelId(channelId);
+ mPreviousScannedChannels.add(channel);
+ }
+ } while (cursor.moveToNext());
+ }
+ }
+ mIsScanning.set(true);
+ }
+
+ /**
+ * Invoked when completing the scanning mode. Passes {@code MSG_SCAN_COMPLETED} to the handler
+ * in order to wait for finishing the remaining messages in the handler queue. Then removes the
+ * obsolete channels, which are previously scanned but are not in the current scanned result.
+ */
+ public void notifyScanCompleted() {
+ // Send a dummy message to check whether there is any MSG_HANDLE_CHANNEL in queue
+ // and avoid race conditions.
+ scanCompleted.set(true);
+ mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(MSG_HANDLE_CHANNEL, null));
+ }
+
+ public void scannedChannelHandlingCompleted() {
+ mIsScanning.set(false);
+ if (!mPreviousScannedChannels.isEmpty()) {
+ ArrayList<ContentProviderOperation> ops = new ArrayList<>();
+ for (TunerChannel channel : mPreviousScannedChannels) {
+ ops.add(ContentProviderOperation.newDelete(
+ TvContract.buildChannelUri(channel.getChannelId())).build());
+ }
+ try {
+ mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
+ } catch (RemoteException | OperationApplicationException e) {
+ Log.e(TAG, "Error deleting obsolete channels", e);
+ }
+ }
+ if (mChannelScanListener != null && mChannelScanHandler != null) {
+ mChannelScanHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mChannelScanListener.onChannelHandlingDone();
+ }
+ });
+ } else {
+ Log.e(TAG, "Error. mChannelScanListener is null.");
+ }
+ }
+
+ /**
+ * Returns the number of scanned channels in the scanning mode.
+ */
+ public int getScannedChannelCount() {
+ return mScannedChannels.size();
+ }
+
+ /**
+ * Removes all callbacks and messages in handler to avoid previous messages from last channel.
+ */
+ public void removeAllCallbacksAndMessages() {
+ mHandler.removeCallbacksAndMessages(null);
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_HANDLE_EVENTS: {
+ ChannelEvent event = (ChannelEvent) msg.obj;
+ handleEvents(event.channel, event.eitItems);
+ return true;
+ }
+ case MSG_HANDLE_CHANNEL: {
+ TunerChannel channel = (TunerChannel) msg.obj;
+ if (channel != null) {
+ handleChannel(channel);
+ }
+ if (scanCompleted.get() && mIsScanning.get()
+ && !mHandler.hasMessages(MSG_HANDLE_CHANNEL)) {
+ // Complete the scan when all found channels have already been handled.
+ scannedChannelHandlingCompleted();
+ }
+ return true;
+ }
+ case MSG_BUILD_CHANNEL_MAP: {
+ mHandler.removeMessages(MSG_BUILD_CHANNEL_MAP);
+ buildChannelMap();
+ return true;
+ }
+ case MSG_REQUEST_PROGRAMS: {
+ if (mHandler.hasMessages(MSG_REQUEST_PROGRAMS)) {
+ return true;
+ }
+ TunerChannel channel = (TunerChannel) msg.obj;
+ if (mListener != null) {
+ mListener.onRequestProgramsResponse(channel, getAllProgramsForChannel(channel));
+ }
+ return true;
+ }
+ case MSG_CLEAR_CHANNELS: {
+ clearChannels();
+ return true;
+ }
+ case MSG_CHECK_VERSION: {
+ checkVersion();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Private methods
+ private void handleEvents(TunerChannel channel, List<EitItem> items) {
+ long channelId = getChannelId(channel);
+ if (channelId <= 0) {
+ return;
+ }
+ channel.setChannelId(channelId);
+
+ // Schedule the audio and caption tracks of the current program and the programs being
+ // listed after the current one into TIS.
+ if (mListener != null) {
+ mListener.onProgramsArrived(channel, items);
+ }
+
+ long currentTime = System.currentTimeMillis();
+ List<EitItem> oldItems = getAllProgramsForChannel(channel, currentTime,
+ currentTime + PROGRAM_QUERY_DURATION);
+ ArrayList<ContentProviderOperation> ops = new ArrayList<>();
+ // TODO: Find a right way to check if the programs are added outside.
+ boolean addedOutside = false;
+ for (EitItem item : oldItems) {
+ if (item.getEventId() == 0) {
+ // The event has been added outside TV tuner.
+ addedOutside = true;
+ break;
+ }
+ }
+
+ // Inserting programs only when there is no overlapping with existing data assuming that:
+ // 1. external EPG is more accurate and rich and
+ // 2. the data we add here will be updated when we apply external EPG.
+ if (addedOutside) {
+ // oldItemCount cannot be 0 if addedOutside is true.
+ int oldItemCount = oldItems.size();
+ for (EitItem newItem : items) {
+ if (newItem.getEndTimeUtcMillis() < currentTime) {
+ continue;
+ }
+ long newItemStartTime = newItem.getStartTimeUtcMillis();
+ long newItemEndTime = newItem.getEndTimeUtcMillis();
+ if (newItemStartTime < oldItems.get(0).getStartTimeUtcMillis()) {
+ // Start time smaller than that of any old items. Insert if no overlap.
+ if (newItemEndTime > oldItems.get(0).getStartTimeUtcMillis()) continue;
+ } else if (newItemStartTime
+ > oldItems.get(oldItemCount - 1).getStartTimeUtcMillis()) {
+ // Start time larger than that of any old item. Insert if no overlap.
+ if (newItemStartTime
+ < oldItems.get(oldItemCount - 1).getEndTimeUtcMillis()) continue;
+ } else {
+ int pos = Collections.binarySearch(oldItems, newItem,
+ new Comparator<EitItem>() {
+ @Override
+ public int compare(EitItem lhs, EitItem rhs) {
+ return Long.compare(lhs.getStartTimeUtcMillis(),
+ rhs.getStartTimeUtcMillis());
+ }
+ });
+ if (pos >= 0) {
+ // Same start Time found. Overlapped.
+ continue;
+ }
+ int insertPoint = -1 - pos;
+ // Check the two adjacent items.
+ if (newItemStartTime < oldItems.get(insertPoint - 1).getEndTimeUtcMillis()
+ || newItemEndTime > oldItems.get(insertPoint).getStartTimeUtcMillis()) {
+ continue;
+ }
+ }
+ ops.add(buildContentProviderOperation(ContentProviderOperation.newInsert(
+ TvContract.Programs.CONTENT_URI), newItem, channel.getChannelId()));
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ }
+ applyBatch(channel.getName(), ops);
+ return;
+ }
+
+ List<EitItem> outdatedOldItems = new ArrayList<>();
+ Map<Integer, EitItem> newEitItemMap = new HashMap<>();
+ for (EitItem item : items) {
+ newEitItemMap.put(item.getEventId(), item);
+ }
+ for (EitItem oldItem : oldItems) {
+ EitItem item = newEitItemMap.get(oldItem.getEventId());
+ if (item == null) {
+ outdatedOldItems.add(oldItem);
+ continue;
+ }
+
+ // Since program descriptions arrive at different time, the older one may have the
+ // correct program description while the newer one has no clue what value is.
+ if (oldItem.getDescription() != null && item.getDescription() == null
+ && oldItem.getEventId() == item.getEventId()
+ && oldItem.getStartTime() == item.getStartTime()
+ && oldItem.getLengthInSecond() == item.getLengthInSecond()
+ && Objects.equals(oldItem.getContentRating(), item.getContentRating())
+ && Objects.equals(oldItem.getBroadcastGenre(), item.getBroadcastGenre())
+ && Objects.equals(oldItem.getCanonicalGenre(), item.getCanonicalGenre())) {
+ item.setDescription(oldItem.getDescription());
+ }
+ if (item.compareTo(oldItem) != 0) {
+ ops.add(buildContentProviderOperation(ContentProviderOperation.newUpdate(
+ TvContract.buildProgramUri(oldItem.getProgramId())), item, null));
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ }
+ newEitItemMap.remove(item.getEventId());
+ }
+ for (EitItem unverifiedOldItems : outdatedOldItems) {
+ if (unverifiedOldItems.getStartTimeUtcMillis() > currentTime) {
+ // The given new EIT item list covers partial time span of EPG. Here, we delete old
+ // item only when it has an overlapping with the new EIT item list.
+ long startTime = unverifiedOldItems.getStartTimeUtcMillis();
+ long endTime = unverifiedOldItems.getEndTimeUtcMillis();
+ for (EitItem item : newEitItemMap.values()) {
+ long newItemStartTime = item.getStartTimeUtcMillis();
+ long newItemEndTime = item.getEndTimeUtcMillis();
+ if ((startTime >= newItemStartTime && startTime < newItemEndTime)
+ || (endTime > newItemStartTime && endTime <= newItemEndTime)) {
+ ops.add(ContentProviderOperation.newDelete(TvContract.buildProgramUri(
+ unverifiedOldItems.getProgramId())).build());
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ break;
+ }
+ }
+ }
+ }
+ for (EitItem item : newEitItemMap.values()) {
+ if (item.getEndTimeUtcMillis() < currentTime) {
+ continue;
+ }
+ ops.add(buildContentProviderOperation(ContentProviderOperation.newInsert(
+ TvContract.Programs.CONTENT_URI), item, channel.getChannelId()));
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ }
+
+ applyBatch(channel.getName(), ops);
+ }
+
+ private ContentProviderOperation buildContentProviderOperation(
+ ContentProviderOperation.Builder builder, EitItem item, Long channelId) {
+ if (channelId != null) {
+ builder.withValue(TvContract.Programs.COLUMN_CHANNEL_ID, channelId);
+ }
+ if (item != null) {
+ builder.withValue(TvContract.Programs.COLUMN_TITLE, item.getTitleText())
+ .withValue(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
+ item.getStartTimeUtcMillis())
+ .withValue(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS,
+ item.getEndTimeUtcMillis())
+ .withValue(TvContract.Programs.COLUMN_CONTENT_RATING,
+ item.getContentRating())
+ .withValue(TvContract.Programs.COLUMN_AUDIO_LANGUAGE,
+ item.getAudioLanguage())
+ .withValue(TvContract.Programs.COLUMN_SHORT_DESCRIPTION,
+ item.getDescription())
+ .withValue(TvContract.Programs.COLUMN_VERSION_NUMBER,
+ item.getEventId());
+ }
+ return builder.build();
+ }
+
+ private void applyBatch(String channelName, ArrayList<ContentProviderOperation> operations) {
+ try {
+ mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, operations);
+ } catch (RemoteException | OperationApplicationException e) {
+ Log.e(TAG, "Error updating EPG " + channelName, e);
+ }
+ }
+
+ private void handleChannel(TunerChannel channel) {
+ long channelId = getChannelId(channel);
+ ContentValues values = new ContentValues();
+ values.put(TvContract.Channels.COLUMN_NETWORK_AFFILIATION, channel.getShortName());
+ values.put(TvContract.Channels.COLUMN_SERVICE_TYPE, channel.getServiceTypeName());
+ values.put(TvContract.Channels.COLUMN_TRANSPORT_STREAM_ID, channel.getTsid());
+ values.put(TvContract.Channels.COLUMN_DISPLAY_NUMBER, channel.getDisplayNumber());
+ values.put(TvContract.Channels.COLUMN_DISPLAY_NAME, channel.getName());
+ values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, channel.toByteArray());
+ values.put(TvContract.Channels.COLUMN_DESCRIPTION, channel.getDescription());
+ values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1, VERSION);
+
+ if (channelId <= 0) {
+ values.put(TvContract.Channels.COLUMN_INPUT_ID, mInputId);
+ values.put(TvContract.Channels.COLUMN_TYPE, "QAM256".equals(channel.getModulation())
+ ? TvContract.Channels.TYPE_ATSC_C : TvContract.Channels.TYPE_ATSC_T);
+ values.put(TvContract.Channels.COLUMN_SERVICE_ID, channel.getProgramNumber());
+
+ // ATSC doesn't have original_network_id
+ values.put(TvContract.Channels.COLUMN_ORIGINAL_NETWORK_ID, channel.getFrequency());
+
+ Uri channelUri = mContext.getContentResolver().insert(TvContract.Channels.CONTENT_URI,
+ values);
+ channelId = ContentUris.parseId(channelUri);
+ } else {
+ mContext.getContentResolver().update(
+ TvContract.buildChannelUri(channelId), values, null, null);
+ }
+ channel.setChannelId(channelId);
+ mTunerChannelMap.put(channelId, channel);
+ mTunerChannelIdMap.put(channel, channelId);
+ if (mIsScanning.get()) {
+ mScannedChannels.add(channel);
+ mPreviousScannedChannels.remove(channel);
+ }
+ if (mListener != null) {
+ mListener.onChannelArrived(channel);
+ }
+ }
+
+ private void clearChannels() {
+ int count = mContext.getContentResolver().delete(mChannelsUri, null, null);
+ if (count > 0) {
+ // We have just deleted obsolete data. Now tell the user that he or she needs
+ // to perform the auto-scan again.
+ if (mListener != null) {
+ mListener.onRescanNeeded();
+ }
+ }
+ }
+
+ private void checkVersion() {
+ String selection = TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1 + "<>?";
+ try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri,
+ CHANNEL_DATA_SELECTION_ARGS, selection,
+ new String[] {Integer.toString(VERSION)}, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ // The stored channel data seem outdated. Delete them all.
+ clearChannels();
+ }
+ }
+ }
+
+ private long getChannelId(TunerChannel channel) {
+ Long channelId = mTunerChannelIdMap.get(channel);
+ if (channelId != null) {
+ return channelId;
+ }
+ mHandler.sendEmptyMessage(MSG_BUILD_CHANNEL_MAP);
+ try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri,
+ CHANNEL_DATA_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ channelId = cursor.getLong(0);
+ byte[] providerData = cursor.getBlob(1);
+ TunerChannel tunerChannel = TunerChannel.parseFrom(providerData);
+ if (tunerChannel != null && tunerChannel.compareTo(channel) == 0) {
+ channel.setChannelId(channelId);
+ mTunerChannelIdMap.put(channel, channelId);
+ mTunerChannelMap.put(channelId, channel);
+ return channelId;
+ }
+ } while (cursor.moveToNext());
+ }
+ }
+ return -1;
+ }
+
+ private List<EitItem> getAllProgramsForChannel(TunerChannel channel) {
+ return getAllProgramsForChannel(channel, null, null);
+ }
+
+ private List<EitItem> getAllProgramsForChannel(TunerChannel channel, @Nullable Long startTimeMs,
+ @Nullable Long endTimeMs) {
+ List<EitItem> items = new ArrayList<>();
+ long channelId = channel.getChannelId();
+ Uri programsUri = (startTimeMs == null || endTimeMs == null) ?
+ TvContract.buildProgramsUriForChannel(channelId) :
+ TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs);
+ try (Cursor cursor = mContext.getContentResolver().query(programsUri,
+ ALL_PROGRAMS_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ long id = cursor.getLong(0);
+ String titleText = cursor.getString(1);
+ long startTime = ConvertUtils.convertUnixEpochToGPSTime(
+ cursor.getLong(2) / DateUtils.SECOND_IN_MILLIS);
+ long endTime = ConvertUtils.convertUnixEpochToGPSTime(
+ cursor.getLong(3) / DateUtils.SECOND_IN_MILLIS);
+ int lengthInSecond = (int) (endTime - startTime);
+ String contentRating = cursor.getString(4);
+ String broadcastGenre = cursor.getString(5);
+ String canonicalGenre = cursor.getString(6);
+ String description = cursor.getString(7);
+ int eventId = cursor.getInt(8);
+ EitItem eitItem = new EitItem(id, eventId, titleText, startTime, lengthInSecond,
+ contentRating, null, null, broadcastGenre, canonicalGenre, description);
+ items.add(eitItem);
+ } while (cursor.moveToNext());
+ }
+ }
+ return items;
+ }
+
+ private void buildChannelMap() {
+ ArrayList<TunerChannel> channels = new ArrayList<>();
+ try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri,
+ CHANNEL_DATA_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ long channelId = cursor.getLong(0);
+ byte[] data = cursor.getBlob(1);
+ TunerChannel channel = TunerChannel.parseFrom(data);
+ if (channel != null) {
+ channel.setChannelId(channelId);
+ channels.add(channel);
+ }
+ } while (cursor.moveToNext());
+ }
+ }
+ mTunerChannelMap.clear();
+ mTunerChannelIdMap.clear();
+ for (TunerChannel channel : channels) {
+ mTunerChannelMap.put(channel.getChannelId(), channel);
+ mTunerChannelIdMap.put(channel, channel.getChannelId());
+ }
+ }
+
+ private static class ChannelEvent {
+ public final TunerChannel channel;
+ public final List<EitItem> eitItems;
+
+ public ChannelEvent(TunerChannel channel, List<EitItem> eitItems) {
+ this.channel = channel;
+ this.eitItems = eitItems;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/EventDetector.java b/src/com/android/tv/tuner/tvinput/EventDetector.java
new file mode 100644
index 00000000..27bbb8c7
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/EventDetector.java
@@ -0,0 +1,261 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
+
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.ts.TsParser;
+import com.android.tv.tuner.data.PsiData;
+import com.android.tv.tuner.data.PsipData;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Detects channels and programs that are emerged or changed while parsing ATSC PSIP information.
+ */
+public class EventDetector {
+ private static final String TAG = "EventDetector";
+ private static final boolean DEBUG = false;
+ public static final int ALL_PROGRAM_NUMBERS = -1;
+
+ private final TunerHal mTunerHal;
+
+ private TsParser mTsParser;
+ private final Set<Integer> mPidSet = new HashSet<>();
+
+ // To prevent channel duplication
+ private final Set<Integer> mVctProgramNumberSet = new HashSet<>();
+ private final SparseArray<TunerChannel> mChannelMap = new SparseArray<>();
+ private final SparseBooleanArray mVctCaptionTracksFound = new SparseBooleanArray();
+ private final SparseBooleanArray mEitCaptionTracksFound = new SparseBooleanArray();
+ private final EventListener mEventListener;
+ private int mFrequency;
+ private String mModulation;
+ private int mProgramNumber = ALL_PROGRAM_NUMBERS;
+
+ private final TsParser.TsOutputListener mTsOutputListener = new TsParser.TsOutputListener() {
+ @Override
+ public void onPatDetected(List<PsiData.PatItem> items) {
+ for (PsiData.PatItem i : items) {
+ if (mProgramNumber == ALL_PROGRAM_NUMBERS || mProgramNumber == i.getProgramNo()) {
+ mTunerHal.addPidFilter(i.getPmtPid(), TunerHal.FILTER_TYPE_OTHER);
+ }
+ }
+ }
+
+ @Override
+ public void onEitPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onEitItemParsed(PsipData.VctItem channel, List<PsipData.EitItem> items) {
+ TunerChannel tunerChannel = mChannelMap.get(channel.getProgramNumber());
+ if (DEBUG) {
+ Log.d(TAG, "onEitItemParsed tunerChannel:" + tunerChannel + " "
+ + channel.getProgramNumber());
+ }
+ int channelSourceId = channel.getSourceId();
+
+ // Source id 0 is useful for cases where a cable operator wishes to define a channel for
+ // which no EPG data is currently available.
+ // We don't handle such a case.
+ if (channelSourceId == 0) {
+ return;
+ }
+
+ // If at least a one caption track have been found in EIT items for the given channel,
+ // we starts to interpret the zero tracks as a clearance of the caption tracks.
+ boolean captionTracksFound = mEitCaptionTracksFound.get(channelSourceId);
+ for (PsipData.EitItem item : items) {
+ if (captionTracksFound) {
+ break;
+ }
+ List<AtscCaptionTrack> captionTracks = item.getCaptionTracks();
+ if (captionTracks != null && !captionTracks.isEmpty()) {
+ captionTracksFound = true;
+ }
+ }
+ mEitCaptionTracksFound.put(channelSourceId, captionTracksFound);
+ if (captionTracksFound) {
+ for (PsipData.EitItem item : items) {
+ item.setHasCaptionTrack();
+ }
+ }
+ if (tunerChannel != null && mEventListener != null) {
+ mEventListener.onEventDetected(tunerChannel, items);
+ }
+ }
+
+ @Override
+ public void onEttPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onAllVctItemsParsed() {
+ if (mEventListener != null) {
+ mEventListener.onChannelScanDone();
+ }
+ }
+
+ @Override
+ public void onVctItemParsed(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "onVctItemParsed VCT " + channel);
+ Log.d(TAG, " PMT " + pmtItems);
+ }
+
+ // Merges the audio and caption tracks located in PMT items into the tracks of the given
+ // tuner channel.
+ TunerChannel tunerChannel = new TunerChannel(channel, pmtItems);
+ List<AtscAudioTrack> audioTracks = new ArrayList<>();
+ List<AtscCaptionTrack> captionTracks = new ArrayList<>();
+ for (PsiData.PmtItem pmtItem : pmtItems) {
+ if (pmtItem.getAudioTracks() != null) {
+ audioTracks.addAll(pmtItem.getAudioTracks());
+ }
+ if (pmtItem.getCaptionTracks() != null) {
+ captionTracks.addAll(pmtItem.getCaptionTracks());
+ }
+ }
+ int channelProgramNumber = channel.getProgramNumber();
+
+ // If at least a one caption track have been found in VCT items for the given channel,
+ // we starts to interpret the zero tracks as a clearance of the caption tracks.
+ boolean captionTracksFound = mVctCaptionTracksFound.get(channelProgramNumber)
+ || !captionTracks.isEmpty();
+ mVctCaptionTracksFound.put(channelProgramNumber, captionTracksFound);
+ if (captionTracksFound) {
+ tunerChannel.setHasCaptionTrack();
+ }
+ tunerChannel.setAudioTracks(audioTracks);
+ tunerChannel.setCaptionTracks(captionTracks);
+ tunerChannel.setFrequency(mFrequency);
+ tunerChannel.setModulation(mModulation);
+ mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel);
+ boolean found = mVctProgramNumberSet.contains(channelProgramNumber);
+ if (!found) {
+ mVctProgramNumberSet.add(channelProgramNumber);
+ }
+ if (mEventListener != null) {
+ mEventListener.onChannelDetected(tunerChannel, !found);
+ }
+ }
+ };
+
+ /**
+ * Listener for detecting ATSC TV channels and receiving EPG data.
+ */
+ public interface EventListener {
+
+ /**
+ * Fired when new information of an ATSC TV channel arrived.
+ *
+ * @param channel an ATSC TV channel
+ * @param channelArrivedAtFirstTime tells whether this channel arrived at first time
+ */
+ void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime);
+
+ /**
+ * Fired when new program events of an ATSC TV channel arrived.
+ *
+ * @param channel an ATSC TV channel
+ * @param items a list of EIT items that were received
+ */
+ void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items);
+
+ /**
+ * Fired when information of all detectable ATSC TV channels in current frequency arrived.
+ */
+ void onChannelScanDone();
+ }
+
+ /**
+ * Creates a detector for ATSC TV channles and program information.
+ * @param usbTunerInteface {@link TunerHal}
+ * @param listener for ATSC TV channels and program information
+ */
+ public EventDetector(TunerHal usbTunerInteface, EventListener listener) {
+ mTunerHal = usbTunerInteface;
+ mEventListener = listener;
+ }
+
+ private void reset() {
+ mTsParser = new TsParser(mTsOutputListener); // TODO: Use TsParser.reset()
+ mPidSet.clear();
+ mVctProgramNumberSet.clear();
+ mVctCaptionTracksFound.clear();
+ mEitCaptionTracksFound.clear();
+ mChannelMap.clear();
+ }
+
+ /**
+ * Starts detecting channel and program information.
+ *
+ * @param frequency The frequency to listen to.
+ * @param modulation The modulation type.
+ * @param programNumber The program number if this is for handling tune request. For scanning
+ * purpose, supply {@link #ALL_PROGRAM_NUMBERS}.
+ */
+ public void startDetecting(int frequency, String modulation, int programNumber) {
+ reset();
+ mFrequency = frequency;
+ mModulation = modulation;
+ mProgramNumber = programNumber;
+ }
+
+ private void startListening(int pid) {
+ if (mPidSet.contains(pid)) {
+ return;
+ }
+ mPidSet.add(pid);
+ mTunerHal.addPidFilter(pid, TunerHal.FILTER_TYPE_OTHER);
+ }
+
+ /**
+ * Feeds ATSC TS stream to detect channel and program information.
+ * @param data buffer for ATSC TS stream
+ * @param startOffset the offset where buffer starts
+ * @param length The length of available data
+ */
+ public void feedTSStream(byte[] data, int startOffset, int length) {
+ if (mPidSet.isEmpty()) {
+ startListening(TsParser.ATSC_SI_BASE_PID);
+ }
+ if (mTsParser != null) {
+ mTsParser.feedTSData(data, startOffset, length);
+ }
+ }
+
+ /**
+ * Retrieves the channel information regardless of being well-formed.
+ * @return {@link List} of {@link TunerChannel}
+ */
+ public List<TunerChannel> getMalFormedChannels() {
+ return mTsParser.getMalFormedChannels();
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java
new file mode 100644
index 00000000..46ff4ea1
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java
@@ -0,0 +1,210 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
+
+import com.android.tv.tuner.data.PsiData.PatItem;
+import com.android.tv.tuner.data.PsiData.PmtItem;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.PsipData.VctItem;
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.source.FileTsStreamer;
+import com.android.tv.tuner.ts.TsParser;
+import com.android.tv.tuner.tvinput.EventDetector.EventListener;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * PSIP event detector for a file source.
+ *
+ * <p>Uses {@link TsParser} to analyze input MPEG-2 transport stream, detects and reports
+ * various PSIP-related events via {@link TsParser.TsOutputListener}.
+ */
+public class FileSourceEventDetector {
+ private static final String TAG = "FileSourceEventDetector";
+ private static final boolean DEBUG = true;
+ public static final int ALL_PROGRAM_NUMBERS = 0;
+
+ private TsParser mTsParser;
+ private final Set<Integer> mVctProgramNumberSet = new HashSet<>();
+ private final SparseArray<TunerChannel> mChannelMap = new SparseArray<>();
+ private final SparseBooleanArray mVctCaptionTracksFound = new SparseBooleanArray();
+ private final SparseBooleanArray mEitCaptionTracksFound = new SparseBooleanArray();
+ private final EventListener mEventListener;
+ private FileTsStreamer.StreamProvider mStreamProvider;
+ private int mProgramNumber = ALL_PROGRAM_NUMBERS;
+
+ public FileSourceEventDetector(EventDetector.EventListener listener) {
+ mEventListener = listener;
+ }
+
+ /**
+ * Starts detecting channel and program information.
+ *
+ * @param provider MPEG-2 transport stream source.
+ * @param programNumber The program number if this is for handling tune request. For scanning
+ * purpose, supply {@link #ALL_PROGRAM_NUMBERS}.
+ */
+ public void start(FileTsStreamer.StreamProvider provider, int programNumber) {
+ mStreamProvider = provider;
+ mProgramNumber = programNumber;
+ reset();
+ }
+
+ private void reset() {
+ mTsParser = new TsParser(mTsOutputListener); // TODO: Use TsParser.reset()
+ mStreamProvider.clearPidFilter();
+ mVctProgramNumberSet.clear();
+ mVctCaptionTracksFound.clear();
+ mEitCaptionTracksFound.clear();
+ mChannelMap.clear();
+ }
+
+ public void feedTSStream(byte[] data, int startOffset, int length) {
+ if (mStreamProvider.isFilterEmpty()) {
+ startListening(TsParser.ATSC_SI_BASE_PID);
+ startListening(TsParser.PAT_PID);
+ }
+ if (mTsParser != null) {
+ mTsParser.feedTSData(data, startOffset, length);
+ }
+ }
+
+ private void startListening(int pid) {
+ if (mStreamProvider.isInFilter(pid)) {
+ return;
+ }
+ mStreamProvider.addPidFilter(pid);
+ }
+
+ private final TsParser.TsOutputListener mTsOutputListener = new TsParser.TsOutputListener() {
+ @Override
+ public void onPatDetected(List<PatItem> items) {
+ for (PatItem i : items) {
+ if (mProgramNumber == ALL_PROGRAM_NUMBERS || mProgramNumber == i.getProgramNo()) {
+ mStreamProvider.addPidFilter(i.getPmtPid());
+ }
+ }
+ }
+
+ @Override
+ public void onEitPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onEitItemParsed(VctItem channel, List<EitItem> items) {
+ TunerChannel tunerChannel = mChannelMap.get(channel.getProgramNumber());
+ if (DEBUG) {
+ Log.d(TAG, "onEitItemParsed tunerChannel:" + tunerChannel + " "
+ + channel.getProgramNumber());
+ }
+ int channelSourceId = channel.getSourceId();
+
+ // Source id 0 is useful for cases where a cable operator wishes to define a channel for
+ // which no EPG data is currently available.
+ // We don't handle such a case.
+ if (channelSourceId == 0) {
+ return;
+ }
+
+ // If at least a one caption track have been found in EIT items for the given channel,
+ // we starts to interpret the zero tracks as a clearance of the caption tracks.
+ boolean captionTracksFound = mEitCaptionTracksFound.get(channelSourceId);
+ for (EitItem item : items) {
+ if (captionTracksFound) {
+ break;
+ }
+ List<AtscCaptionTrack> captionTracks = item.getCaptionTracks();
+ if (captionTracks != null && !captionTracks.isEmpty()) {
+ captionTracksFound = true;
+ }
+ }
+ mEitCaptionTracksFound.put(channelSourceId, captionTracksFound);
+ if (captionTracksFound) {
+ for (EitItem item : items) {
+ item.setHasCaptionTrack();
+ }
+ }
+ if (tunerChannel != null && mEventListener != null) {
+ mEventListener.onEventDetected(tunerChannel, items);
+ }
+ }
+
+ @Override
+ public void onEttPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onAllVctItemsParsed() {
+ // do nothing.
+ }
+
+ @Override
+ public void onVctItemParsed(VctItem channel, List<PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "onVctItemParsed VCT " + channel);
+ Log.d(TAG, " PMT " + pmtItems);
+ }
+
+ // Merges the audio and caption tracks located in PMT items into the tracks of the given
+ // tuner channel.
+ TunerChannel tunerChannel = TunerChannel.forFile(channel, pmtItems);
+ List<AtscAudioTrack> audioTracks = new ArrayList<>();
+ List<AtscCaptionTrack> captionTracks = new ArrayList<>();
+ for (PmtItem pmtItem : pmtItems) {
+ if (pmtItem.getAudioTracks() != null) {
+ audioTracks.addAll(pmtItem.getAudioTracks());
+ }
+ if (pmtItem.getCaptionTracks() != null) {
+ captionTracks.addAll(pmtItem.getCaptionTracks());
+ }
+ }
+ int channelProgramNumber = channel.getProgramNumber();
+
+ // If at least a one caption track have been found in VCT items for the given channel,
+ // we starts to interpret the zero tracks as a clearance of the caption tracks.
+ boolean captionTracksFound = mVctCaptionTracksFound.get(channelProgramNumber)
+ || !captionTracks.isEmpty();
+ mVctCaptionTracksFound.put(channelProgramNumber, captionTracksFound);
+ if (captionTracksFound) {
+ tunerChannel.setHasCaptionTrack();
+ }
+ tunerChannel.setFilepath(mStreamProvider.getFilepath());
+ tunerChannel.setAudioTracks(audioTracks);
+ tunerChannel.setCaptionTracks(captionTracks);
+
+ mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel);
+ boolean found = mVctProgramNumberSet.contains(channelProgramNumber);
+ if (!found) {
+ mVctProgramNumberSet.add(channelProgramNumber);
+ }
+ if (mEventListener != null) {
+ mEventListener.onChannelDetected(tunerChannel, !found);
+ }
+ }
+ };
+}
diff --git a/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java b/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java
new file mode 100644
index 00000000..3908fe6c
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java
@@ -0,0 +1,42 @@
+/*
+ * 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.tuner.tvinput;
+
+/**
+ * The listener for buffer events occurred during playback.
+ */
+public interface PlaybackBufferListener {
+
+ /**
+ * Invoked when the start position of the buffer has been changed.
+ *
+ * @param startTimeMs the new start time of the buffer in millisecond
+ */
+ void onBufferStartTimeChanged(long startTimeMs);
+
+ /**
+ * Invoked when the state of the buffer has been changed.
+ *
+ * @param available whether the buffer is available or not
+ */
+ void onBufferStateChanged(boolean available);
+
+ /**
+ * Invoked when the disk speed is too slow to write the buffers.
+ */
+ void onDiskTooSlow();
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerDebug.java b/src/com/android/tv/tuner/tvinput/TunerDebug.java
new file mode 100644
index 00000000..a7a41ea7
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerDebug.java
@@ -0,0 +1,150 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+/**
+ * A class to maintain various debugging information.
+ */
+public class TunerDebug {
+ private static final String TAG = "TunerDebug";
+ public static final boolean ENABLED = false;
+
+ private int mVideoFrameDrop;
+ private int mBytesInQueue;
+
+ private long mAudioPositionUs;
+ private long mAudioPtsUs;
+ private long mVideoPtsUs;
+
+ private long mLastAudioPositionUs;
+ private long mLastAudioPtsUs;
+ private long mLastVideoPtsUs;
+ private long mLastCheckTimestampMs;
+
+ private long mAudioPositionUsRate;
+ private long mAudioPtsUsRate;
+ private long mVideoPtsUsRate;
+
+ private TunerDebug() {
+ mVideoFrameDrop = 0;
+ mLastCheckTimestampMs = SystemClock.elapsedRealtime();
+ }
+
+ private static class LazyHolder {
+ private static final TunerDebug INSTANCE = new TunerDebug();
+ }
+
+ public static TunerDebug getInstance() {
+ return LazyHolder.INSTANCE;
+ }
+
+ public static void notifyVideoFrameDrop(long delta) {
+ // TODO: provide timestamp mismatch information using delta
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mVideoFrameDrop++;
+ }
+
+ public static int getVideoFrameDrop() {
+ TunerDebug sTunerDebug = getInstance();
+ int videoFrameDrop = sTunerDebug.mVideoFrameDrop;
+ if (videoFrameDrop > 0) {
+ Log.d(TAG, "Dropped video frame: " + videoFrameDrop);
+ }
+ sTunerDebug.mVideoFrameDrop = 0;
+ return videoFrameDrop;
+ }
+
+ public static void setBytesInQueue(int bytesInQueue) {
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mBytesInQueue = bytesInQueue;
+ }
+
+ public static int getBytesInQueue() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mBytesInQueue;
+ }
+
+ public static void setAudioPositionUs(long audioPositionUs) {
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mAudioPositionUs = audioPositionUs;
+ }
+
+ public static long getAudioPositionUs() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mAudioPositionUs;
+ }
+
+ public static void setAudioPtsUs(long audioPtsUs) {
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mAudioPtsUs = audioPtsUs;
+ }
+
+ public static long getAudioPtsUs() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mAudioPtsUs;
+ }
+
+ public static void setVideoPtsUs(long videoPtsUs) {
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mVideoPtsUs = videoPtsUs;
+ }
+
+ public static long getVideoPtsUs() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mVideoPtsUs;
+ }
+
+ public static void calculateDiff() {
+ TunerDebug sTunerDebug = getInstance();
+ long currentTime = SystemClock.elapsedRealtime();
+ long duration = currentTime - sTunerDebug.mLastCheckTimestampMs;
+ if (duration != 0) {
+ sTunerDebug.mAudioPositionUsRate =
+ (sTunerDebug.mAudioPositionUs - sTunerDebug.mLastAudioPositionUs) * 1000
+ / duration;
+ sTunerDebug.mAudioPtsUsRate =
+ (sTunerDebug.mAudioPtsUs - sTunerDebug.mLastAudioPtsUs) * 1000
+ / duration;
+ sTunerDebug.mVideoPtsUsRate =
+ (sTunerDebug.mVideoPtsUs - sTunerDebug.mLastVideoPtsUs) * 1000
+ / duration;
+ }
+
+ sTunerDebug.mLastAudioPositionUs = sTunerDebug.mAudioPositionUs;
+ sTunerDebug.mLastAudioPtsUs = sTunerDebug.mAudioPtsUs;
+ sTunerDebug.mLastVideoPtsUs = sTunerDebug.mVideoPtsUs;
+ sTunerDebug.mLastCheckTimestampMs = currentTime;
+ }
+
+ public static long getAudioPositionUsRate() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mAudioPositionUsRate;
+ }
+
+ public static long getAudioPtsUsRate() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mAudioPtsUsRate;
+ }
+
+ public static long getVideoPtsUsRate() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mVideoPtsUsRate;
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java
new file mode 100644
index 00000000..acdd149f
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java
@@ -0,0 +1,104 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.content.Context;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvInputService;
+import android.net.Uri;
+import android.support.annotation.MainThread;
+import android.support.annotation.Nullable;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+
+/**
+ * Processes DVR recordings, and deletes the previously recorded contents.
+ */
+public class TunerRecordingSession extends TvInputService.RecordingSession {
+ private static final String TAG = "TunerRecordingSession";
+ private static final boolean DEBUG = false;
+
+ private final TunerRecordingSessionWorker mSessionWorker;
+
+ public TunerRecordingSession(Context context, String inputId,
+ ChannelDataManager channelDataManager) {
+ super(context);
+ mSessionWorker = new TunerRecordingSessionWorker(context, inputId, channelDataManager,
+ this);
+ }
+
+ // RecordingSession
+ @MainThread
+ @Override
+ public void onTune(Uri channelUri) {
+ // TODO(dvr): support calling more than once, http://b/27171225
+ if (DEBUG) {
+ Log.d(TAG, "Requesting recording session tune: " + channelUri);
+ }
+ mSessionWorker.tune(channelUri);
+ }
+
+ @MainThread
+ @Override
+ public void onRelease() {
+ if (DEBUG) {
+ Log.d(TAG, "Requesting recording session release.");
+ }
+ mSessionWorker.release();
+ }
+
+ @MainThread
+ @Override
+ public void onStartRecording(@Nullable Uri programUri) {
+ if (DEBUG) {
+ Log.d(TAG, "Requesting start recording.");
+ }
+ mSessionWorker.startRecording(programUri);
+ }
+
+ @MainThread
+ @Override
+ public void onStopRecording() {
+ if (DEBUG) {
+ Log.d(TAG, "Requesting stop recording.");
+ }
+ mSessionWorker.stopRecording();
+ }
+
+ // Called from TunerRecordingSessionImpl in a worker thread.
+ @WorkerThread
+ public void onTuned(Uri channelUri) {
+ if (DEBUG) {
+ Log.d(TAG, "Notifying recording session tuned.");
+ }
+ notifyTuned(channelUri);
+ }
+
+ @WorkerThread
+ public void onRecordFinished(final Uri recordedProgramUri) {
+ if (DEBUG) {
+ Log.d(TAG, "Notifying record successfully finished.");
+ }
+ notifyRecordingStopped(recordedProgramUri);
+ }
+
+ @WorkerThread
+ public void onError(int reason) {
+ Log.w(TAG, "Notifying recording error: " + reason);
+ notifyError(reason);
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java
new file mode 100644
index 00000000..6ec55e4f
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java
@@ -0,0 +1,594 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.media.tv.TvInputManager;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.support.annotation.IntDef;
+import android.support.annotation.MainThread;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.recording.RecordingCapability;
+import com.android.tv.dvr.DvrStorageStatusManager;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.tuner.DvbDeviceAccessor;
+import com.android.tv.tuner.data.PsipData;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.exoplayer.ExoPlayerSampleExtractor;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.DvrStorageManager;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsDataSourceManager;
+import com.android.tv.util.Utils;
+
+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;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Implements a DVR feature.
+ */
+public class TunerRecordingSessionWorker implements PlaybackBufferListener,
+ EventDetector.EventListener, SampleExtractor.OnCompletionListener,
+ Handler.Callback {
+ private static final String TAG = "TunerRecordingSessionW";
+ 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 long STORAGE_MONITOR_INTERVAL_MS = TimeUnit.SECONDS.toMillis(4);
+ private static final long MIN_PARTIAL_RECORDING_DURATION_MS = TimeUnit.SECONDS.toMillis(10);
+ private static final long PREPARE_RECORDER_POLL_MS = 50;
+ private static final int MSG_TUNE = 1;
+ private static final int MSG_START_RECORDING = 2;
+ private static final int MSG_PREPARE_RECODER = 3;
+ private static final int MSG_STOP_RECORDING = 4;
+ private static final int MSG_MONITOR_STORAGE_STATUS = 5;
+ private static final int MSG_RELEASE = 6;
+ private final RecordingCapability mCapabilities;
+
+ public RecordingCapability getCapabilities() {
+ return mCapabilities;
+ }
+
+ @IntDef({STATE_IDLE, STATE_TUNED, STATE_RECORDING})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface DvrSessionState {}
+ private static final int STATE_IDLE = 1;
+ private static final int STATE_TUNED = 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 DvrStorageStatusManager mDvrStorageStatusManager;
+ private final Handler mHandler;
+ private final TsDataSourceManager mSourceManager;
+ private final Random mRandom = new Random();
+
+ private TsDataSource mTunerSource;
+ private TunerChannel mChannel;
+ private File mStorageDir;
+ private long mRecordStartTime;
+ private long mRecordEndTime;
+ private boolean mRecorderRunning;
+ private BufferManager mBufferManager;
+ private SampleExtractor mRecorder;
+ private final TunerRecordingSession mSession;
+ @DvrSessionState private int mSessionState = STATE_IDLE;
+ private final String mInputId;
+ private Uri mProgramUri;
+
+ 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);
+ mDvrStorageStatusManager =
+ TvApplication.getSingletons(context).getDvrStorageStatusManager();
+ mChannelDataManager = dataManager;
+ mChannelDataManager.checkDataVersion(context);
+ mSourceManager = TsDataSourceManager.createSourceManager(true);
+ mCapabilities = new DvbDeviceAccessor(context).getRecordingCapability(inputId);
+ mInputId = inputId;
+ if (DEBUG) Log.d(TAG, mCapabilities.toString());
+ mSession = session;
+ }
+
+ // PlaybackBufferListener
+ @Override
+ public void onBufferStartTimeChanged(long startTimeMs) { }
+
+ @Override
+ public void onBufferStateChanged(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<PsipData.EitItem> items) {
+ if (mChannel == null || mChannel.compareTo(channel) != 0) {
+ return;
+ }
+ mChannelDataManager.notifyEventDetected(channel, items);
+ }
+
+ @Override
+ public void onChannelScanDone() {
+ // do nothing.
+ }
+
+ // SampleExtractor.OnCompletionListener
+ @Override
+ public void onCompletion(boolean success, long lastExtractedPositionUs) {
+ onRecordingResult(success, lastExtractedPositionUs);
+ reset();
+ }
+
+ /**
+ * Tunes to {@code channelUri}.
+ */
+ @MainThread
+ public void tune(Uri channelUri) {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.obtainMessage(MSG_TUNE, channelUri).sendToTarget();
+ }
+
+ /**
+ * Starts recording.
+ */
+ @MainThread
+ public void startRecording(@Nullable Uri programUri) {
+ mHandler.obtainMessage(MSG_START_RECORDING, programUri).sendToTarget();
+ }
+
+ /**
+ * Stops recording.
+ */
+ @MainThread
+ public void stopRecording() {
+ mHandler.sendEmptyMessage(MSG_STOP_RECORDING);
+ }
+
+ /**
+ * Releases all resources.
+ */
+ @MainThread
+ public void release() {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.sendEmptyMessage(MSG_RELEASE);
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_TUNE: {
+ Uri channelUri = (Uri) msg.obj;
+ if (DEBUG) Log.d(TAG, "Tune to " + channelUri);
+ if (doTune(channelUri)) {
+ mSession.onTuned(channelUri);
+ } else {
+ reset();
+ }
+ return true;
+ }
+ case MSG_START_RECORDING: {
+ if (DEBUG) Log.d(TAG, "Start recording");
+ if (!doStartRecording((Uri) msg.obj)) {
+ reset();
+ }
+ return true;
+ }
+ case MSG_PREPARE_RECODER: {
+ if (DEBUG) Log.d(TAG, "Preparing recorder");
+ if (!mRecorderRunning) {
+ return true;
+ }
+ try {
+ if (!mRecorder.prepare()) {
+ mHandler.sendEmptyMessageDelayed(MSG_PREPARE_RECODER,
+ PREPARE_RECORDER_POLL_MS);
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to start recording. Couldn't prepare an extractor");
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ reset();
+ }
+ return true;
+ }
+ case MSG_STOP_RECORDING: {
+ if (DEBUG) Log.d(TAG, "Stop recording");
+ if (mSessionState != STATE_RECORDING) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ reset();
+ return true;
+ }
+ if (mRecorderRunning) {
+ stopRecorder();
+ }
+ return true;
+ }
+ case MSG_MONITOR_STORAGE_STATUS: {
+ if (mSessionState != STATE_RECORDING) {
+ return true;
+ }
+ if (!mDvrStorageStatusManager.isStorageSufficient()) {
+ if (mRecorderRunning) {
+ stopRecorder();
+ }
+ new DeleteRecordingTask().execute(mStorageDir);
+ mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ reset();
+ } else {
+ mHandler.sendEmptyMessageDelayed(MSG_MONITOR_STORAGE_STATUS,
+ STORAGE_MONITOR_INTERVAL_MS);
+ }
+ return true;
+ }
+ case MSG_RELEASE: {
+ // Since release was requested, current recording will be cancelled
+ // without notification.
+ reset();
+ mSourceManager.release();
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.getLooper().quitSafely();
+ 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 void reset() {
+ if (mRecorder != null) {
+ mRecorder.release();
+ mRecorder = null;
+ }
+ if (mBufferManager != null) {
+ mBufferManager.close();
+ mBufferManager = null;
+ }
+ if (mTunerSource != null) {
+ mSourceManager.releaseDataSource(mTunerSource);
+ mTunerSource = null;
+ }
+ mSessionState = STATE_IDLE;
+ mRecorderRunning = false;
+ }
+
+ private boolean doTune(Uri channelUri) {
+ if (mSessionState != STATE_IDLE) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.e(TAG, "Tuning was requested from wrong status.");
+ return false;
+ }
+ mChannel = getChannel(channelUri);
+ if (mChannel == null) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.w(TAG, "Failed to start recording. Couldn't find the channel for " + mChannel);
+ return false;
+ }
+ if (!mDvrStorageStatusManager.isStorageSufficient()) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ Log.w(TAG, "Tuning failed due to insufficient storage.");
+ return false;
+ }
+ mTunerSource = mSourceManager.createDataSource(mContext, mChannel, this);
+ if (mTunerSource == null) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_RESOURCE_BUSY);
+ Log.w(TAG, "Tuner stream cannot be created due to resource shortage.");
+ return false;
+ }
+ mSessionState = STATE_TUNED;
+ return true;
+ }
+
+ private boolean doStartRecording(@Nullable Uri programUri) {
+ if (mSessionState != STATE_TUNED) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.e(TAG, "Recording session status abnormal");
+ return false;
+ }
+ mStorageDir = mDvrStorageStatusManager.isStorageSufficient() ?
+ new File(mDvrStorageStatusManager.getRecordingRootDataDirectory(),
+ getStorageKey()) : null;
+ if (mStorageDir == null) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ Log.w(TAG, "Failed to start recording due to insufficient storage.");
+ return false;
+ }
+ // Since tuning might be happened a while ago, shifts the start position of tuned source.
+ mTunerSource.shiftStartPosition(mTunerSource.getBufferedPosition());
+ mBufferManager = new BufferManager(new DvrStorageManager(mStorageDir, true));
+ mRecordStartTime = System.currentTimeMillis();
+ mRecorder = new ExoPlayerSampleExtractor(Uri.EMPTY, mTunerSource, mBufferManager, this,
+ true);
+ mRecorder.setOnCompletionListener(this, mHandler);
+ mProgramUri = programUri;
+ mSessionState = STATE_RECORDING;
+ mRecorderRunning = true;
+ mHandler.sendEmptyMessage(MSG_PREPARE_RECODER);
+ mHandler.removeMessages(MSG_MONITOR_STORAGE_STATUS);
+ mHandler.sendEmptyMessageDelayed(MSG_MONITOR_STORAGE_STATUS,
+ STORAGE_MONITOR_INTERVAL_MS);
+ return true;
+ }
+
+ private void stopRecorder() {
+ // Do not change session status.
+ if (mRecorder != null) {
+ mRecorder.release();
+ mRecordEndTime = System.currentTimeMillis();
+ mRecorder = null;
+ }
+ mRecorderRunning = false;
+ mHandler.removeMessages(MSG_MONITOR_STORAGE_STATUS);
+ Log.i(TAG, "Recording stopped");
+ }
+
+ private static class Program {
+ private final long mChannelId;
+ private final String mTitle;
+ private String mSeriesId;
+ private final String mSeasonTitle;
+ private final String mEpisodeTitle;
+ private final String mSeasonNumber;
+ private final String mEpisodeNumber;
+ private final String mDescription;
+ private final String mPosterArtUri;
+ private final String mThumbnailUri;
+ private final String mCanonicalGenres;
+ private final String mContentRatings;
+ private final long mStartTimeUtcMillis;
+ private final long mEndTimeUtcMillis;
+ private final int mVideoWidth;
+ private final int mVideoHeight;
+ private final byte[] mInternalProviderData;
+
+ private static final String[] PROJECTION = {
+ TvContract.Programs.COLUMN_CHANNEL_ID,
+ TvContract.Programs.COLUMN_TITLE,
+ TvContract.Programs.COLUMN_SEASON_TITLE,
+ TvContract.Programs.COLUMN_EPISODE_TITLE,
+ TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER,
+ TvContract.Programs.COLUMN_EPISODE_DISPLAY_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,
+ TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA
+ };
+
+ public Program(Cursor cursor) {
+ int index = 0;
+ mChannelId = cursor.getLong(index++);
+ mTitle = cursor.getString(index++);
+ mSeasonTitle = cursor.getString(index++);
+ mEpisodeTitle = cursor.getString(index++);
+ mSeasonNumber = cursor.getString(index++);
+ mEpisodeNumber = cursor.getString(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.getInt(index++);
+ mVideoHeight = cursor.getInt(index++);
+ mInternalProviderData = cursor.getBlob(index++);
+ SoftPreconditions.checkArgument(index == PROJECTION.length);
+ }
+
+ public Program(long channelId) {
+ mChannelId = channelId;
+ mTitle = "Unknown";
+ mSeasonTitle = "";
+ mEpisodeTitle = "";
+ mSeasonNumber = "";
+ mEpisodeNumber = "";
+ mDescription = "Unknown";
+ mPosterArtUri = null;
+ mThumbnailUri = null;
+ mCanonicalGenres = null;
+ mContentRatings = null;
+ mStartTimeUtcMillis = 0;
+ mEndTimeUtcMillis = 0;
+ mVideoWidth = 0;
+ mVideoHeight = 0;
+ mInternalProviderData = null;
+ }
+
+ 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();
+ int index = 0;
+ values.put(PROJECTION[index++], mChannelId);
+ values.put(PROJECTION[index++], mTitle);
+ values.put(PROJECTION[index++], mSeasonTitle);
+ values.put(PROJECTION[index++], mEpisodeTitle);
+ values.put(PROJECTION[index++], mSeasonNumber);
+ values.put(PROJECTION[index++], mEpisodeNumber);
+ values.put(PROJECTION[index++], mDescription);
+ values.put(PROJECTION[index++], mPosterArtUri);
+ values.put(PROJECTION[index++], mThumbnailUri);
+ values.put(PROJECTION[index++], mCanonicalGenres);
+ values.put(PROJECTION[index++], mContentRatings);
+ values.put(PROJECTION[index++], mStartTimeUtcMillis);
+ values.put(PROJECTION[index++], mEndTimeUtcMillis);
+ values.put(PROJECTION[index++], mVideoWidth);
+ values.put(PROJECTION[index++], mVideoHeight);
+ values.put(PROJECTION[index++], mInternalProviderData);
+ SoftPreconditions.checkArgument(index == PROJECTION.length);
+ return values;
+ }
+ }
+
+ private Program getRecordedProgram() {
+ ContentResolver resolver = mContext.getContentResolver();
+ Uri programUri = mProgramUri;
+ if (mProgramUri == null) {
+ long avg = mRecordStartTime / 2 + mRecordEndTime / 2;
+ 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) {
+ // TODO: Set title even though program is null.
+ RecordedProgram recordedProgram = RecordedProgram.builder()
+ .setInputId(mInputId)
+ .setChannelId(channelId)
+ .setDataUri(storageUri)
+ .setDurationMillis(endTime - startTime)
+ .setDataBytes(totalBytes)
+ // startTime and endTime could be overridden by program's start and end value.
+ .setStartTimeUtcMillis(startTime)
+ .setEndTimeUtcMillis(endTime)
+ .build();
+ ContentValues values = RecordedProgram.toValues(recordedProgram);
+ if (program != null) {
+ values.putAll(program.buildValues());
+ }
+ return mContext.getContentResolver().insert(TvContract.RecordedPrograms.CONTENT_URI,
+ values);
+ }
+
+ private void onRecordingResult(boolean success, long lastExtractedPositionUs) {
+ if (mSessionState != STATE_RECORDING) {
+ // Error notification is not needed.
+ Log.e(TAG, "Recording session status abnormal");
+ return;
+ }
+ if (mRecorderRunning) {
+ // In case of recorder not being stopped, because of premature termination of recording.
+ stopRecorder();
+ }
+ if (!success && lastExtractedPositionUs <
+ TimeUnit.MILLISECONDS.toMicros(MIN_PARTIAL_RECORDING_DURATION_MS)) {
+ new DeleteRecordingTask().execute(mStorageDir);
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.w(TAG, "Recording failed during recording");
+ return;
+ }
+ Log.i(TAG, "recording finished " + (success ? "completely" : "partially"));
+ Uri uri = insertRecordedProgram(getRecordedProgram(), mChannel.getChannelId(),
+ Uri.fromFile(mStorageDir).toString(), 1024 * 1024, mRecordStartTime,
+ mRecordStartTime + TimeUnit.MICROSECONDS.toMillis(lastExtractedPositionUs));
+ if (uri == null) {
+ new DeleteRecordingTask().execute(mStorageDir);
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.e(TAG, "Inserting a recording to DB failed");
+ return;
+ }
+ mSession.onRecordFinished(uri);
+ }
+
+ private static class DeleteRecordingTask extends AsyncTask<File, Void, Void> {
+
+ @Override
+ public Void doInBackground(File... files) {
+ if (files == null || files.length == 0) {
+ return null;
+ }
+ for(File file : files) {
+ Utils.deleteDirOrFile(file);
+ }
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerSession.java b/src/com/android/tv/tuner/tvinput/TunerSession.java
new file mode 100644
index 00000000..abfd2b30
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerSession.java
@@ -0,0 +1,312 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.media.PlaybackParams;
+import android.media.tv.TvContentRating;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvInputService;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Message;
+import android.os.SystemClock;
+import android.text.Html;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.cc.CaptionLayout;
+import com.android.tv.tuner.cc.CaptionTrackRenderer;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.util.GlobalSettingsUtils;
+import com.android.tv.tuner.util.StatusTextUtils;
+import com.android.tv.tuner.util.SystemPropertiesProxy;
+
+/**
+ * Provides a tuner TV input session. It handles Overlay UI works. Main tuner input functions
+ * are implemented in {@link TunerSessionWorker}.
+ */
+public class TunerSession extends TvInputService.Session implements Handler.Callback {
+ private static final String TAG = "TunerSession";
+ private static final boolean DEBUG = false;
+ private static final String USBTUNER_SHOW_DEBUG = "persist.tv.tuner.show_debug";
+
+ public static final int MSG_UI_SHOW_MESSAGE = 1;
+ public static final int MSG_UI_HIDE_MESSAGE = 2;
+ public static final int MSG_UI_SHOW_AUDIO_UNPLAYABLE = 3;
+ public static final int MSG_UI_HIDE_AUDIO_UNPLAYABLE = 4;
+ public static final int MSG_UI_PROCESS_CAPTION_TRACK = 5;
+ public static final int MSG_UI_START_CAPTION_TRACK = 6;
+ public static final int MSG_UI_STOP_CAPTION_TRACK = 7;
+ public static final int MSG_UI_RESET_CAPTION_TRACK = 8;
+ public static final int MSG_UI_SET_STATUS_TEXT = 9;
+ public static final int MSG_UI_TOAST_RESCAN_NEEDED = 10;
+
+ private final Context mContext;
+ private final Handler mUiHandler;
+ private final View mOverlayView;
+ private final TextView mMessageView;
+ private final TextView mStatusView;
+ private final TextView mAudioStatusView;
+ private final ViewGroup mMessageLayout;
+ private final CaptionTrackRenderer mCaptionTrackRenderer;
+ private final TunerSessionWorker mSessionWorker;
+ private boolean mReleased = false;
+ private boolean mPlayPaused;
+ private long mTuneStartTimestamp;
+
+ public TunerSession(Context context, ChannelDataManager channelDataManager,
+ BufferManager bufferManager) {
+ super(context);
+ mContext = context;
+ mUiHandler = new Handler(this);
+ LayoutInflater inflater = (LayoutInflater)
+ context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mOverlayView = inflater.inflate(R.layout.ut_overlay_view, null);
+ mMessageLayout = (ViewGroup) mOverlayView.findViewById(R.id.message_layout);
+ mMessageLayout.setVisibility(View.INVISIBLE);
+ mMessageView = (TextView) mOverlayView.findViewById(R.id.message);
+ mStatusView = (TextView) mOverlayView.findViewById(R.id.tuner_status);
+ boolean showDebug = SystemPropertiesProxy.getBoolean(USBTUNER_SHOW_DEBUG, false);
+ mStatusView.setVisibility(showDebug ? View.VISIBLE : View.INVISIBLE);
+ mAudioStatusView = (TextView) mOverlayView.findViewById(R.id.audio_status);
+ mAudioStatusView.setVisibility(View.INVISIBLE);
+ mAudioStatusView.setText(Html.fromHtml(StatusTextUtils.getAudioWarningInHTML(
+ context.getString(R.string.ut_surround_sound_disabled))));
+ CaptionLayout captionLayout = (CaptionLayout) mOverlayView.findViewById(R.id.caption);
+ mCaptionTrackRenderer = new CaptionTrackRenderer(captionLayout);
+ mSessionWorker = new TunerSessionWorker(context, channelDataManager,
+ bufferManager, this);
+ }
+
+ public boolean isReleased() {
+ return mReleased;
+ }
+
+ @Override
+ public View onCreateOverlayView() {
+ return mOverlayView;
+ }
+
+ @Override
+ public boolean onSelectTrack(int type, String trackId) {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_SELECT_TRACK, type, 0, trackId);
+ return false;
+ }
+
+ @Override
+ public void onSetCaptionEnabled(boolean enabled) {
+ mSessionWorker.setCaptionEnabled(enabled);
+ }
+
+ @Override
+ public void onSetStreamVolume(float volume) {
+ mSessionWorker.setStreamVolume(volume);
+ }
+
+ @Override
+ public boolean onSetSurface(Surface surface) {
+ mSessionWorker.setSurface(surface);
+ return true;
+ }
+
+ @Override
+ public void onTimeShiftPause() {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_PAUSE);
+ mPlayPaused = true;
+ }
+
+ @Override
+ public void onTimeShiftResume() {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_RESUME);
+ mPlayPaused = false;
+ }
+
+ @Override
+ public void onTimeShiftSeekTo(long timeMs) {
+ if (DEBUG) Log.d(TAG, "Timeshift seekTo requested position: " + timeMs / 1000);
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_SEEK_TO,
+ mPlayPaused ? 1 : 0, 0, timeMs);
+ }
+
+ @Override
+ public void onTimeShiftSetPlaybackParams(PlaybackParams params) {
+ mSessionWorker.sendMessage(
+ TunerSessionWorker.MSG_TIMESHIFT_SET_PLAYBACKPARAMS, params);
+ }
+
+ @Override
+ public long onTimeShiftGetStartPosition() {
+ return mSessionWorker.getStartPosition();
+ }
+
+ @Override
+ public long onTimeShiftGetCurrentPosition() {
+ return mSessionWorker.getCurrentPosition();
+ }
+
+ @Override
+ public boolean onTune(Uri channelUri) {
+ if (DEBUG) {
+ Log.d(TAG, "onTune to " + channelUri != null ? channelUri.toString() : "");
+ }
+ if (channelUri == null) {
+ Log.w(TAG, "onTune() is failed due to null channelUri.");
+ mSessionWorker.stopTune();
+ return false;
+ }
+ mTuneStartTimestamp = SystemClock.elapsedRealtime();
+ mSessionWorker.tune(channelUri);
+ mPlayPaused = false;
+ return true;
+ }
+
+ @TargetApi(Build.VERSION_CODES.N)
+ @Override
+ public void onTimeShiftPlay(Uri recordUri) {
+ if (recordUri == null) {
+ Log.w(TAG, "onTimeShiftPlay() is failed due to null channelUri.");
+ mSessionWorker.stopTune();
+ return;
+ }
+ mTuneStartTimestamp = SystemClock.elapsedRealtime();
+ mSessionWorker.tune(recordUri);
+ mPlayPaused = false;
+ }
+
+ @Override
+ public void onUnblockContent(TvContentRating unblockedRating) {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_UNBLOCKED_RATING,
+ unblockedRating);
+ }
+
+ @Override
+ public void onRelease() {
+ if (DEBUG) {
+ Log.d(TAG, "onRelease");
+ }
+ mReleased = true;
+ mSessionWorker.release();
+ mUiHandler.removeCallbacksAndMessages(null);
+ }
+
+ /**
+ * Sets {@link AudioCapabilities}.
+ */
+ public void setAudioCapabilities(AudioCapabilities audioCapabilities) {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_AUDIO_CAPABILITIES_CHANGED,
+ audioCapabilities);
+ }
+
+ @Override
+ public void notifyVideoAvailable() {
+ super.notifyVideoAvailable();
+ if (mTuneStartTimestamp != 0) {
+ Log.i(TAG, "[Profiler] Video available in "
+ + (SystemClock.elapsedRealtime() - mTuneStartTimestamp) + " ms");
+ mTuneStartTimestamp = 0;
+ }
+ }
+
+ @Override
+ public void notifyVideoUnavailable(int reason) {
+ super.notifyVideoUnavailable(reason);
+ if (reason != TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING
+ && reason != TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL) {
+ notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE);
+ }
+ }
+
+ public void sendUiMessage(int message) {
+ mUiHandler.sendEmptyMessage(message);
+ }
+
+ public void sendUiMessage(int message, Object object) {
+ mUiHandler.obtainMessage(message, object).sendToTarget();
+ }
+
+ public void sendUiMessage(int message, int arg1, int arg2, Object object) {
+ mUiHandler.obtainMessage(message, arg1, arg2, object).sendToTarget();
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_UI_SHOW_MESSAGE: {
+ mMessageView.setText((String) msg.obj);
+ mMessageLayout.setVisibility(View.VISIBLE);
+ return true;
+ }
+ case MSG_UI_HIDE_MESSAGE: {
+ mMessageLayout.setVisibility(View.INVISIBLE);
+ return true;
+ }
+ case MSG_UI_SHOW_AUDIO_UNPLAYABLE: {
+ // Showing message of enabling surround sound only when global surround sound
+ // setting is "never".
+ final int value = GlobalSettingsUtils.getEncodedSurroundOutputSettings(mContext);
+ if (value == GlobalSettingsUtils.ENCODED_SURROUND_OUTPUT_NEVER) {
+ mAudioStatusView.setVisibility(View.VISIBLE);
+ } else {
+ Log.e(TAG, "Audio is unavailable, surround sound setting is " + value);
+ }
+ return true;
+ }
+ case MSG_UI_HIDE_AUDIO_UNPLAYABLE: {
+ mAudioStatusView.setVisibility(View.INVISIBLE);
+ return true;
+ }
+ case MSG_UI_PROCESS_CAPTION_TRACK: {
+ mCaptionTrackRenderer.processCaptionEvent((CaptionEvent) msg.obj);
+ return true;
+ }
+ case MSG_UI_START_CAPTION_TRACK: {
+ mCaptionTrackRenderer.start((AtscCaptionTrack) msg.obj);
+ return true;
+ }
+ case MSG_UI_STOP_CAPTION_TRACK: {
+ mCaptionTrackRenderer.stop();
+ return true;
+ }
+ case MSG_UI_RESET_CAPTION_TRACK: {
+ mCaptionTrackRenderer.reset();
+ return true;
+ }
+ case MSG_UI_SET_STATUS_TEXT: {
+ mStatusView.setText((CharSequence) msg.obj);
+ return true;
+ }
+ case MSG_UI_TOAST_RESCAN_NEEDED: {
+ Toast.makeText(mContext, R.string.ut_rescan_needed, Toast.LENGTH_LONG).show();
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java
new file mode 100644
index 00000000..c0a613a4
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java
@@ -0,0 +1,1583 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.Cursor;
+import android.media.MediaFormat;
+import android.media.PlaybackParams;
+import android.media.tv.TvContentRating;
+import android.media.tv.TvContract;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvTrackInfo;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.SystemClock;
+import android.support.annotation.AnyThread;
+import android.support.annotation.MainThread;
+import android.support.annotation.WorkerThread;
+import android.text.Html;
+import android.util.Log;
+import android.util.Pair;
+import android.util.SparseArray;
+import android.view.Surface;
+import android.view.accessibility.CaptioningManager;
+
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.google.android.exoplayer.ExoPlayer;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.TvContentRatingCache;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.Cea708Data;
+import com.android.tv.tuner.data.Channel;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.PsipData.TvTracksInterface;
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.exoplayer.MpegTsRendererBuilder;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.DvrStorageManager;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsDataSourceManager;
+import com.android.tv.tuner.util.StatusTextUtils;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * {@link TunerSessionWorker} implements a handler thread which processes TV input jobs
+ * such as handling {@link ExoPlayer}, managing a tuner device, trickplay, and so on.
+ */
+@WorkerThread
+public class TunerSessionWorker implements PlaybackBufferListener,
+ MpegTsPlayer.VideoEventListener, MpegTsPlayer.Listener, EventDetector.EventListener,
+ ChannelDataManager.ProgramInfoListener, Handler.Callback {
+ private static final String TAG = "TunerSessionWorker";
+ private static final boolean DEBUG = false;
+ private static final boolean ENABLE_PROFILER = true;
+ private static final String PLAY_FROM_CHANNEL = "channel";
+
+ // Public messages
+ public static final int MSG_SELECT_TRACK = 1;
+ public static final int MSG_UPDATE_CAPTION_TRACK = 2;
+ public static final int MSG_SET_STREAM_VOLUME = 3;
+ public static final int MSG_TIMESHIFT_PAUSE = 4;
+ public static final int MSG_TIMESHIFT_RESUME = 5;
+ public static final int MSG_TIMESHIFT_SEEK_TO = 6;
+ public static final int MSG_TIMESHIFT_SET_PLAYBACKPARAMS = 7;
+ public static final int MSG_AUDIO_CAPABILITIES_CHANGED = 8;
+ public static final int MSG_UNBLOCKED_RATING = 9;
+
+ // Private messages
+ private static final int MSG_TUNE = 1000;
+ private static final int MSG_RELEASE = 1001;
+ private static final int MSG_RETRY_PLAYBACK = 1002;
+ private static final int MSG_START_PLAYBACK = 1003;
+ private static final int MSG_UPDATE_PROGRAM = 1008;
+ private static final int MSG_SCHEDULE_OF_PROGRAMS = 1009;
+ private static final int MSG_UPDATE_CHANNEL_INFO = 1010;
+ private static final int MSG_TRICKPLAY_BY_SEEK = 1011;
+ private static final int MSG_SMOOTH_TRICKPLAY_MONITOR = 1012;
+ private static final int MSG_PARENTAL_CONTROLS = 1015;
+ private static final int MSG_RESCHEDULE_PROGRAMS = 1016;
+ private static final int MSG_BUFFER_START_TIME_CHANGED = 1017;
+ private static final int MSG_CHECK_SIGNAL = 1018;
+ private static final int MSG_DISCOVER_CAPTION_SERVICE_NUMBER = 1019;
+ private static final int MSG_RESET_PLAYBACK = 1020;
+ private static final int MSG_BUFFER_STATE_CHANGED = 1021;
+ private static final int MSG_PROGRAM_DATA_RESULT = 1022;
+ private static final int MSG_STOP_TUNE = 1023;
+ private static final int MSG_SET_SURFACE = 1024;
+ private static final int MSG_NOTIFY_AUDIO_TRACK_UPDATED = 1025;
+
+ private static final int TS_PACKET_SIZE = 188;
+ private static final int CHECK_NO_SIGNAL_INITIAL_DELAY_MS = 4000;
+ private static final int CHECK_NO_SIGNAL_PERIOD_MS = 500;
+ private static final int RECOVER_STOPPED_PLAYBACK_PERIOD_MS = 2500;
+ private static final int PARENTAL_CONTROLS_INTERVAL_MS = 5000;
+ private static final int RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS = 4000;
+ private static final int RESCHEDULE_PROGRAMS_INTERVAL_MS = 10000;
+ private static final int RESCHEDULE_PROGRAMS_TOLERANCE_MS = 2000;
+ // The following 3s is defined empirically. This should be larger than 2s considering video
+ // key frame interval in the TS stream.
+ private static final int PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS = 3000;
+ private static final int PLAYBACK_RETRY_DELAY_MS = 5000;
+ private static final int MAX_IMMEDIATE_RETRY_COUNT = 5;
+ private static final long INVALID_TIME = -1;
+
+ // Some examples of the track ids of the audio tracks, "a0", "a1", "a2".
+ // The number after prefix is being used for indicating a index of the given audio track.
+ private static final String AUDIO_TRACK_PREFIX = "a";
+
+ // Some examples of the tracks id of the caption tracks, "s1", "s2", "s3".
+ // The number after prefix is being used for indicating a index of a caption service number
+ // of the given caption track.
+ private static final String SUBTITLE_TRACK_PREFIX = "s";
+ private static final int TRACK_PREFIX_SIZE = 1;
+ private static final String VIDEO_TRACK_ID = "v";
+ private static final long BUFFER_UNDERFLOW_BUFFER_MS = 5000;
+
+ // Actual interval would be divided by the speed.
+ private static final int EXPECTED_KEY_FRAME_INTERVAL_MS = 500;
+ private static final int MIN_TRICKPLAY_SEEK_INTERVAL_MS = 20;
+ private static final int TRICKPLAY_MONITOR_INTERVAL_MS = 250;
+
+ private final Context mContext;
+ private final ChannelDataManager mChannelDataManager;
+ private final TsDataSourceManager mSourceManager;
+ private volatile Surface mSurface;
+ private volatile float mVolume = 1.0f;
+ private volatile boolean mCaptionEnabled;
+ private volatile MpegTsPlayer mPlayer;
+ private volatile TunerChannel mChannel;
+ private volatile Long mRecordingDuration;
+ private volatile long mRecordStartTimeMs;
+ private volatile long mBufferStartTimeMs;
+ private String mRecordingId;
+ private final Handler mHandler;
+ private int mRetryCount;
+ private final ArrayList<TvTrackInfo> mTvTracks;
+ private final SparseArray<AtscAudioTrack> mAudioTrackMap;
+ private final SparseArray<AtscCaptionTrack> mCaptionTrackMap;
+ private AtscCaptionTrack mCaptionTrack;
+ private PlaybackParams mPlaybackParams = new PlaybackParams();
+ private boolean mPlayerStarted = false;
+ private boolean mReportedDrawnToSurface = false;
+ private boolean mReportedWeakSignal = false;
+ private EitItem mProgram;
+ private List<EitItem> mPrograms;
+ private final TvInputManager mTvInputManager;
+ private boolean mChannelBlocked;
+ private TvContentRating mUnblockedContentRating;
+ private long mLastPositionMs;
+ private AudioCapabilities mAudioCapabilities;
+ private final CountDownLatch mReleaseLatch = new CountDownLatch(1);
+ private long mLastLimitInBytes;
+ private long mLastPositionInBytes;
+ private final BufferManager mBufferManager;
+ private final TvContentRatingCache mTvContentRatingCache = TvContentRatingCache.getInstance();
+ private final TunerSession mSession;
+ private int mPlayerState = ExoPlayer.STATE_IDLE;
+ private long mPreparingStartTimeMs;
+ private long mBufferingStartTimeMs;
+ private long mReadyStartTimeMs;
+
+ public TunerSessionWorker(Context context, ChannelDataManager channelDataManager,
+ BufferManager bufferManager, TunerSession tunerSession) {
+ if (DEBUG) Log.d(TAG, "TunerSessionWorker created");
+ mContext = context;
+
+ // HandlerThread should be set up before it is registered as a listener in the all other
+ // components.
+ HandlerThread handlerThread = new HandlerThread(TAG);
+ handlerThread.start();
+ mHandler = new Handler(handlerThread.getLooper(), this);
+ mSession = tunerSession;
+ mChannelDataManager = channelDataManager;
+ mChannelDataManager.setListener(this);
+ mChannelDataManager.checkDataVersion(mContext);
+ mSourceManager = TsDataSourceManager.createSourceManager(false);
+ mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
+ mTvTracks = new ArrayList<>();
+ mAudioTrackMap = new SparseArray<>();
+ mCaptionTrackMap = new SparseArray<>();
+ CaptioningManager captioningManager =
+ (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE);
+ mCaptionEnabled = captioningManager.isEnabled();
+ mPlaybackParams.setSpeed(1.0f);
+ mBufferManager = bufferManager;
+ mPreparingStartTimeMs = INVALID_TIME;
+ mBufferingStartTimeMs = INVALID_TIME;
+ mReadyStartTimeMs = INVALID_TIME;
+ }
+
+ // Public methods
+ @MainThread
+ public void tune(Uri channelUri) {
+ mHandler.removeCallbacksAndMessages(null);
+ mSourceManager.setHasPendingTune();
+ sendMessage(MSG_TUNE, channelUri);
+ }
+
+ @MainThread
+ public void stopTune() {
+ mHandler.removeCallbacksAndMessages(null);
+ sendMessage(MSG_STOP_TUNE);
+ }
+
+ /**
+ * Sets {@link Surface}.
+ */
+ @MainThread
+ public void setSurface(Surface surface) {
+ if (surface != null && !surface.isValid()) {
+ Log.w(TAG, "Ignoring invalid surface.");
+ return;
+ }
+ // mSurface is kept even when tune is called right after. But, messages can be deleted by
+ // tune or updateChannelBlockStatus. So mSurface should be stored here, not through message.
+ mSurface = surface;
+ mHandler.sendEmptyMessage(MSG_SET_SURFACE);
+ }
+
+ /**
+ * Sets volume.
+ */
+ @MainThread
+ public void setStreamVolume(float volume) {
+ // mVolume is kept even when tune is called right after. But, messages can be deleted by
+ // tune or updateChannelBlockStatus. So mVolume is stored here and mPlayer.setVolume will be
+ // called in MSG_SET_STREAM_VOLUME.
+ mVolume = volume;
+ mHandler.sendEmptyMessage(MSG_SET_STREAM_VOLUME);
+ }
+
+ /**
+ * Sets if caption is enabled or disabled.
+ */
+ @MainThread
+ public void setCaptionEnabled(boolean captionEnabled) {
+ // mCaptionEnabled is kept even when tune is called right after. But, messages can be
+ // deleted by tune or updateChannelBlockStatus. So mCaptionEnabled is stored here and
+ // start/stopCaptionTrack will be called in MSG_UPDATE_CAPTION_STATUS.
+ mCaptionEnabled = captionEnabled;
+ mHandler.sendEmptyMessage(MSG_UPDATE_CAPTION_TRACK);
+ }
+
+ public TunerChannel getCurrentChannel() {
+ return mChannel;
+ }
+
+ @MainThread
+ public long getStartPosition() {
+ return mBufferStartTimeMs;
+ }
+
+
+ private String getRecordingPath() {
+ return Uri.parse(mRecordingId).getPath();
+ }
+
+ private Long getDurationForRecording(String recordingId) {
+ try {
+ DvrStorageManager storageManager =
+ new DvrStorageManager(new File(getRecordingPath()), false);
+ Pair<String, MediaFormat> trackInfo = null;
+ try {
+ trackInfo = storageManager.readTrackInfoFile(false);
+ } catch (FileNotFoundException e) {
+ }
+ if (trackInfo == null) {
+ trackInfo = storageManager.readTrackInfoFile(true);
+ }
+ Long durationUs = trackInfo.second.getLong(MediaFormat.KEY_DURATION);
+ // we need duration by milli for trickplay notification.
+ return durationUs != null ? durationUs / 1000 : null;
+ } catch (IOException e) {
+ Log.e(TAG, "meta file for recording was not found: " + recordingId);
+ return null;
+ }
+ }
+
+ @MainThread
+ public long getCurrentPosition() {
+ // TODO: More precise time may be necessary.
+ MpegTsPlayer mpegTsPlayer = mPlayer;
+ long currentTime = mpegTsPlayer != null
+ ? mRecordStartTimeMs + mpegTsPlayer.getCurrentPosition() : mRecordStartTimeMs;
+ if (mChannel == null && mPlayerState == ExoPlayer.STATE_ENDED) {
+ currentTime = mRecordingDuration + mRecordStartTimeMs;
+ }
+ if (DEBUG) {
+ long systemCurrentTime = System.currentTimeMillis();
+ Log.d(TAG, "currentTime = " + currentTime
+ + " ; System.currentTimeMillis() = " + systemCurrentTime
+ + " ; diff = " + (currentTime - systemCurrentTime));
+ }
+ return currentTime;
+ }
+
+ @AnyThread
+ public void sendMessage(int messageType) {
+ mHandler.sendEmptyMessage(messageType);
+ }
+
+ @AnyThread
+ public void sendMessage(int messageType, Object object) {
+ mHandler.obtainMessage(messageType, object).sendToTarget();
+ }
+
+ @AnyThread
+ public void sendMessage(int messageType, int arg1, int arg2, Object object) {
+ mHandler.obtainMessage(messageType, arg1, arg2, object).sendToTarget();
+ }
+
+ @MainThread
+ public void release() {
+ if (DEBUG) Log.d(TAG, "release()");
+ mChannelDataManager.setListener(null);
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.sendEmptyMessage(MSG_RELEASE);
+ try {
+ mReleaseLatch.await();
+ } catch (InterruptedException e) {
+ Log.e(TAG, "Couldn't wait for finish of MSG_RELEASE", e);
+ } finally {
+ mHandler.getLooper().quitSafely();
+ }
+ }
+
+ // MpegTsPlayer.Listener
+ // Called in the same thread as mHandler.
+ @Override
+ public void onStateChanged(boolean playWhenReady, int playbackState) {
+ if (DEBUG) Log.d(TAG, "ExoPlayer state change: " + playbackState + " " + playWhenReady);
+ if (playbackState == mPlayerState) {
+ return;
+ }
+ mReadyStartTimeMs = INVALID_TIME;
+ mPreparingStartTimeMs = INVALID_TIME;
+ mBufferingStartTimeMs = INVALID_TIME;
+ if (playbackState == ExoPlayer.STATE_READY) {
+ if (DEBUG) Log.d(TAG, "ExoPlayer ready");
+ if (!mPlayerStarted) {
+ sendMessage(MSG_START_PLAYBACK, mPlayer);
+ }
+ mReadyStartTimeMs = SystemClock.elapsedRealtime();
+ } else if (playbackState == ExoPlayer.STATE_PREPARING) {
+ mPreparingStartTimeMs = SystemClock.elapsedRealtime();
+ } else if (playbackState == ExoPlayer.STATE_BUFFERING) {
+ mBufferingStartTimeMs = SystemClock.elapsedRealtime();
+ } else if (playbackState == ExoPlayer.STATE_ENDED) {
+ // Final status
+ // notification of STATE_ENDED from MpegTsPlayer will be ignored afterwards.
+ Log.i(TAG, "Player ended: end of stream");
+ if (mChannel != null) {
+ sendMessage(MSG_RETRY_PLAYBACK, mPlayer);
+ }
+ }
+ mPlayerState = playbackState;
+ }
+
+ @Override
+ public void onError(Exception e) {
+ if (TunerPreferences.getStoreTsStream(mContext)) {
+ // Crash intentionally to capture the error causing TS file.
+ Log.e(TAG, "Crash intentionally to capture the error causing TS file. "
+ + e.getMessage());
+ SoftPreconditions.checkState(false);
+ }
+ // There maybe some errors that finally raise ExoPlaybackException and will be handled here.
+ // If we are playing live stream, retrying playback maybe helpful. But for recorded stream,
+ // retrying playback is not helpful.
+ if (mChannel != null) {
+ mHandler.obtainMessage(MSG_RETRY_PLAYBACK, mPlayer).sendToTarget();
+ }
+ }
+
+ @Override
+ public void onVideoSizeChanged(int width, int height, float pixelWidthHeight) {
+ if (mChannel != null && mChannel.hasVideo()) {
+ updateVideoTrack(width, height);
+ }
+ if (mRecordingId != null) {
+ updateVideoTrack(width, height);
+ }
+ }
+
+ @Override
+ public void onDrawnToSurface(MpegTsPlayer player, Surface surface) {
+ if (mSurface != null && mPlayerStarted) {
+ if (DEBUG) Log.d(TAG, "MSG_DRAWN_TO_SURFACE");
+ mBufferStartTimeMs = mRecordStartTimeMs =
+ (mRecordingId != null) ? 0 : System.currentTimeMillis();
+ notifyVideoAvailable();
+ mReportedDrawnToSurface = true;
+
+ // If surface is drawn successfully, it means that the playback was brought back
+ // to normal and therefore, the playback recovery status will be reset through
+ // setting a zero value to the retry count.
+ // TODO: Consider audio only channels for detecting playback status changes to
+ // be normal.
+ mRetryCount = 0;
+ if (mCaptionEnabled && mCaptionTrack != null) {
+ startCaptionTrack();
+ } else {
+ stopCaptionTrack();
+ }
+ mHandler.sendEmptyMessage(MSG_NOTIFY_AUDIO_TRACK_UPDATED);
+ }
+ }
+
+ @Override
+ public void onSmoothTrickplayForceStopped() {
+ if (mPlayer == null || !mHandler.hasMessages(MSG_SMOOTH_TRICKPLAY_MONITOR)) {
+ return;
+ }
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ doTrickplayBySeek((int) mPlayer.getCurrentPosition());
+ }
+
+ @Override
+ public void onAudioUnplayable() {
+ if (mPlayer == null) {
+ return;
+ }
+ Log.i(TAG, "AC3 audio cannot be played due to device limitation");
+ mSession.sendUiMessage(
+ TunerSession.MSG_UI_SHOW_AUDIO_UNPLAYABLE);
+ }
+
+ // MpegTsPlayer.VideoEventListener
+ @Override
+ public void onEmitCaptionEvent(Cea708Data.CaptionEvent event) {
+ mSession.sendUiMessage(TunerSession.MSG_UI_PROCESS_CAPTION_TRACK, event);
+ }
+
+ @Override
+ public void onDiscoverCaptionServiceNumber(int serviceNumber) {
+ sendMessage(MSG_DISCOVER_CAPTION_SERVICE_NUMBER, serviceNumber);
+ }
+
+ // ChannelDataManager.ProgramInfoListener
+ @Override
+ public void onProgramsArrived(TunerChannel channel, List<EitItem> programs) {
+ sendMessage(MSG_SCHEDULE_OF_PROGRAMS, new Pair<>(channel, programs));
+ }
+
+ @Override
+ public void onChannelArrived(TunerChannel channel) {
+ sendMessage(MSG_UPDATE_CHANNEL_INFO, channel);
+ }
+
+ @Override
+ public void onRescanNeeded() {
+ mSession.sendUiMessage(TunerSession.MSG_UI_TOAST_RESCAN_NEEDED);
+ }
+
+ @Override
+ public void onRequestProgramsResponse(TunerChannel channel, List<EitItem> programs) {
+ sendMessage(MSG_PROGRAM_DATA_RESULT, new Pair<>(channel, programs));
+ }
+
+ // PlaybackBufferListener
+ @Override
+ public void onBufferStartTimeChanged(long startTimeMs) {
+ sendMessage(MSG_BUFFER_START_TIME_CHANGED, startTimeMs);
+ }
+
+ @Override
+ public void onBufferStateChanged(boolean available) {
+ sendMessage(MSG_BUFFER_STATE_CHANGED, available);
+ }
+
+ @Override
+ public void onDiskTooSlow() {
+ sendMessage(MSG_RETRY_PLAYBACK, mPlayer);
+ }
+
+ // EventDetector.EventListener
+ @Override
+ public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) {
+ mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime);
+ }
+
+ @Override
+ public void onEventDetected(TunerChannel channel, List<EitItem> items) {
+ mChannelDataManager.notifyEventDetected(channel, items);
+ }
+
+ @Override
+ public void onChannelScanDone() {
+ // do nothing.
+ }
+
+ private long parseChannel(Uri uri) {
+ try {
+ List<String> paths = uri.getPathSegments();
+ if (paths.size() > 1 && paths.get(0).equals(PLAY_FROM_CHANNEL)) {
+ return ContentUris.parseId(uri);
+ }
+ } catch (UnsupportedOperationException | NumberFormatException e) {
+ }
+ return -1;
+ }
+
+ private static class RecordedProgram {
+ private final long mChannelId;
+ private final String mDataUri;
+
+ private static final String[] PROJECTION = {
+ TvContract.Programs.COLUMN_CHANNEL_ID,
+ TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI,
+ };
+
+ public RecordedProgram(Cursor cursor) {
+ int index = 0;
+ mChannelId = cursor.getLong(index++);
+ mDataUri = cursor.getString(index++);
+ }
+
+ public RecordedProgram(long channelId, String dataUri) {
+ mChannelId = channelId;
+ mDataUri = dataUri;
+ }
+
+ public static RecordedProgram onQuery(Cursor c) {
+ RecordedProgram recording = null;
+ if (c != null && c.moveToNext()) {
+ recording = new RecordedProgram(c);
+ }
+ return recording;
+ }
+
+ public String getDataUri() {
+ return mDataUri;
+ }
+ }
+
+ private RecordedProgram getRecordedProgram(Uri recordedUri) {
+ ContentResolver resolver = mContext.getContentResolver();
+ try(Cursor c = resolver.query(recordedUri, RecordedProgram.PROJECTION, null, null, null)) {
+ if (c != null) {
+ RecordedProgram result = RecordedProgram.onQuery(c);
+ if (DEBUG) {
+ Log.d(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 String parseRecording(Uri uri) {
+ RecordedProgram recording = getRecordedProgram(uri);
+ if (recording != null) {
+ return recording.getDataUri();
+ }
+ return null;
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_TUNE: {
+ if (DEBUG) Log.d(TAG, "MSG_TUNE");
+
+ // When sequential tuning messages arrived, it skips middle tuning messages in order
+ // to change to the last requested channel quickly.
+ if (mHandler.hasMessages(MSG_TUNE)) {
+ return true;
+ }
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
+ Uri channelUri = (Uri) msg.obj;
+ String recording = null;
+ long channelId = parseChannel(channelUri);
+ TunerChannel channel = (channelId == -1) ? null
+ : mChannelDataManager.getChannel(channelId);
+ if (channelId == -1) {
+ recording = parseRecording(channelUri);
+ }
+ if (channel == null && recording == null) {
+ Log.w(TAG, "onTune() is failed. Can't find channel for " + channelUri);
+ stopTune();
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
+ return true;
+ }
+ mHandler.removeCallbacksAndMessages(null);
+ if (channel != null) {
+ mChannelDataManager.requestProgramsData(channel);
+ }
+ prepareTune(channel, recording);
+ // TODO: Need to refactor. notifyContentAllowed() should not be called if parental
+ // control is turned on.
+ mSession.notifyContentAllowed();
+ resetPlayback();
+ resetTvTracks();
+ mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS,
+ RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS);
+ return true;
+ }
+ case MSG_STOP_TUNE: {
+ if (DEBUG) Log.d(TAG, "MSG_STOP_TUNE");
+ mChannel = null;
+ stopPlayback();
+ stopCaptionTrack();
+ resetTvTracks();
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
+ return true;
+ }
+ case MSG_RELEASE: {
+ if (DEBUG) Log.d(TAG, "MSG_RELEASE");
+ mHandler.removeCallbacksAndMessages(null);
+ stopPlayback();
+ stopCaptionTrack();
+ mSourceManager.release();
+ mReleaseLatch.countDown();
+ return true;
+ }
+ case MSG_RETRY_PLAYBACK: {
+ if (mPlayer == msg.obj) {
+ Log.i(TAG, "Retrying the playback for channel: " + mChannel);
+ mHandler.removeMessages(MSG_RETRY_PLAYBACK);
+ // When there is a request of retrying playback, don't reuse TunerHal.
+ mSourceManager.setKeepTuneStatus(false);
+ mRetryCount++;
+ if (DEBUG) {
+ Log.d(TAG, "MSG_RETRY_PLAYBACK " + mRetryCount);
+ }
+ if (mRetryCount <= MAX_IMMEDIATE_RETRY_COUNT) {
+ resetPlayback();
+ } else {
+ // When it reaches this point, it may be due to an error that occurred in
+ // the tuner device. Calling stopPlayback() resets the tuner device
+ // to recover from the error.
+ stopPlayback();
+ stopCaptionTrack();
+
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+
+ // After MAX_IMMEDIATE_RETRY_COUNT, give some delay of an empirically chosen
+ // value before recovering the playback.
+ mHandler.sendEmptyMessageDelayed(MSG_RESET_PLAYBACK,
+ RECOVER_STOPPED_PLAYBACK_PERIOD_MS);
+ }
+ }
+ return true;
+ }
+ case MSG_RESET_PLAYBACK: {
+ if (DEBUG) Log.d(TAG, "MSG_RESET_PLAYBACK");
+ resetPlayback();
+ return true;
+ }
+ case MSG_START_PLAYBACK: {
+ if (DEBUG) Log.d(TAG, "MSG_START_PLAYBACK");
+ if (mChannel != null || mRecordingId != null) {
+ startPlayback(msg.obj);
+ }
+ return true;
+ }
+ case MSG_UPDATE_PROGRAM: {
+ if (mChannel != null) {
+ EitItem program = (EitItem) msg.obj;
+ updateTvTracks(program, false);
+ mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS);
+ }
+ return true;
+ }
+ case MSG_SCHEDULE_OF_PROGRAMS: {
+ mHandler.removeMessages(MSG_UPDATE_PROGRAM);
+ Pair<TunerChannel, List<EitItem>> pair =
+ (Pair<TunerChannel, List<EitItem>>) msg.obj;
+ TunerChannel channel = pair.first;
+ if (mChannel == null) {
+ return true;
+ }
+ if (mChannel != null && mChannel.compareTo(channel) != 0) {
+ return true;
+ }
+ mPrograms = pair.second;
+ EitItem currentProgram = getCurrentProgram();
+ if (currentProgram == null) {
+ mProgram = null;
+ }
+ long currentTimeMs = getCurrentPosition();
+ if (mPrograms != null) {
+ for (EitItem item : mPrograms) {
+ if (currentProgram != null && currentProgram.compareTo(item) == 0) {
+ if (DEBUG) {
+ Log.d(TAG, "Update current TvTracks " + item);
+ }
+ if (mProgram != null && mProgram.compareTo(item) == 0) {
+ continue;
+ }
+ mProgram = item;
+ updateTvTracks(item, false);
+ } else if (item.getStartTimeUtcMillis() > currentTimeMs) {
+ if (DEBUG) {
+ Log.d(TAG, "Update next TvTracks " + item + " "
+ + (item.getStartTimeUtcMillis() - currentTimeMs));
+ }
+ mHandler.sendMessageDelayed(
+ mHandler.obtainMessage(MSG_UPDATE_PROGRAM, item),
+ item.getStartTimeUtcMillis() - currentTimeMs);
+ }
+ }
+ }
+ mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS);
+ return true;
+ }
+ case MSG_UPDATE_CHANNEL_INFO: {
+ TunerChannel channel = (TunerChannel) msg.obj;
+ if (mChannel != null && mChannel.compareTo(channel) == 0) {
+ updateChannelInfo(channel);
+ }
+ return true;
+ }
+ case MSG_PROGRAM_DATA_RESULT: {
+ TunerChannel channel = (TunerChannel) ((Pair) msg.obj).first;
+
+ // If there already exists, skip it since real-time data is a top priority,
+ if (mChannel != null && mChannel.compareTo(channel) == 0
+ && mPrograms == null && mProgram == null) {
+ sendMessage(MSG_SCHEDULE_OF_PROGRAMS, msg.obj);
+ }
+ return true;
+ }
+ case MSG_TRICKPLAY_BY_SEEK: {
+ if (mPlayer == null) {
+ return true;
+ }
+ doTrickplayBySeek(msg.arg1);
+ return true;
+ }
+ case MSG_SMOOTH_TRICKPLAY_MONITOR: {
+ if (mPlayer == null) {
+ return true;
+ }
+ long systemCurrentTime = System.currentTimeMillis();
+ long position = getCurrentPosition();
+ if (mRecordingId == null) {
+ // Checks if the position exceeds the upper bound when forwarding,
+ // or exceed the lower bound when rewinding.
+ // If the direction is not checked, there can be some issues.
+ // (See b/29939781 for more details.)
+ if ((position > systemCurrentTime && mPlaybackParams.getSpeed() > 0L)
+ || (position < mBufferStartTimeMs && mPlaybackParams.getSpeed() < 0L)) {
+ doTimeShiftResume();
+ return true;
+ }
+ } else {
+ if (position > mRecordingDuration || position < 0) {
+ doTimeShiftPause();
+ return true;
+ }
+ }
+ mHandler.sendEmptyMessageDelayed(MSG_SMOOTH_TRICKPLAY_MONITOR,
+ TRICKPLAY_MONITOR_INTERVAL_MS);
+ return true;
+ }
+ case MSG_RESCHEDULE_PROGRAMS: {
+ doReschedulePrograms();
+ return true;
+ }
+ case MSG_PARENTAL_CONTROLS: {
+ doParentalControls();
+ mHandler.removeMessages(MSG_PARENTAL_CONTROLS);
+ mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS,
+ PARENTAL_CONTROLS_INTERVAL_MS);
+ return true;
+ }
+ case MSG_UNBLOCKED_RATING: {
+ mUnblockedContentRating = (TvContentRating) msg.obj;
+ doParentalControls();
+ mHandler.removeMessages(MSG_PARENTAL_CONTROLS);
+ mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS,
+ PARENTAL_CONTROLS_INTERVAL_MS);
+ return true;
+ }
+ case MSG_DISCOVER_CAPTION_SERVICE_NUMBER: {
+ int serviceNumber = (int) msg.obj;
+ doDiscoverCaptionServiceNumber(serviceNumber);
+ return true;
+ }
+ case MSG_SELECT_TRACK: {
+ if (mChannel != null) {
+ doSelectTrack(msg.arg1, (String) msg.obj);
+ } else if (mRecordingId != null) {
+ // TODO : mChannel == null && mRecordingId != null
+ Log.d(TAG, "track selected for recording");
+ }
+ return true;
+ }
+ case MSG_UPDATE_CAPTION_TRACK: {
+ if (mCaptionEnabled) {
+ startCaptionTrack();
+ } else {
+ stopCaptionTrack();
+ }
+ return true;
+ }
+ case MSG_TIMESHIFT_PAUSE: {
+ if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_PAUSE");
+ if (mPlayer == null) {
+ return true;
+ }
+ doTimeShiftPause();
+ return true;
+ }
+ case MSG_TIMESHIFT_RESUME: {
+ if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_RESUME");
+ if (mPlayer == null) {
+ return true;
+ }
+ doTimeShiftResume();
+ return true;
+ }
+ case MSG_TIMESHIFT_SEEK_TO: {
+ long position = (long) msg.obj;
+ if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_SEEK_TO (position=" + position + ")");
+ if (mPlayer == null) {
+ return true;
+ }
+ doTimeShiftSeekTo(position);
+ return true;
+ }
+ case MSG_TIMESHIFT_SET_PLAYBACKPARAMS: {
+ if (mPlayer == null) {
+ return true;
+ }
+ doTimeShiftSetPlaybackParams((PlaybackParams) msg.obj);
+ return true;
+ }
+ case MSG_AUDIO_CAPABILITIES_CHANGED: {
+ AudioCapabilities capabilities = (AudioCapabilities) msg.obj;
+ if (DEBUG) {
+ Log.d(TAG, "MSG_AUDIO_CAPABILITIES_CHANGED " + capabilities);
+ }
+ if (capabilities == null) {
+ return true;
+ }
+ if (!capabilities.equals(mAudioCapabilities)) {
+ // HDMI supported encodings are changed. restart player.
+ mAudioCapabilities = capabilities;
+ resetPlayback();
+ }
+ return true;
+ }
+ case MSG_SET_STREAM_VOLUME: {
+ if (mPlayer != null && mPlayer.isPlaying()) {
+ mPlayer.setVolume(mVolume);
+ }
+ return true;
+ }
+ case MSG_BUFFER_START_TIME_CHANGED: {
+ if (mPlayer == null) {
+ return true;
+ }
+ mBufferStartTimeMs = (long) msg.obj;
+ if (!hasEnoughBackwardBuffer()
+ && (!mPlayer.isPlaying() || mPlaybackParams.getSpeed() < 1.0f)) {
+ mPlayer.setPlayWhenReady(true);
+ mPlayer.setAudioTrack(true);
+ mPlaybackParams.setSpeed(1.0f);
+ }
+ return true;
+ }
+ case MSG_BUFFER_STATE_CHANGED: {
+ boolean available = (boolean) msg.obj;
+ mSession.notifyTimeShiftStatusChanged(available
+ ? TvInputManager.TIME_SHIFT_STATUS_AVAILABLE
+ : TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE);
+ return true;
+ }
+ case MSG_CHECK_SIGNAL: {
+ if (mChannel == null || mPlayer == null) {
+ return true;
+ }
+ TsDataSource source = mPlayer.getDataSource();
+ long limitInBytes = source != null ? source.getBufferedPosition() : 0L;
+ long positionInBytes = source != null ? source.getLastReadPosition() : 0L;
+ if (TunerDebug.ENABLED) {
+ TunerDebug.calculateDiff();
+ mSession.sendUiMessage(TunerSession.MSG_UI_SET_STATUS_TEXT,
+ Html.fromHtml(
+ StatusTextUtils.getStatusWarningInHTML(
+ (limitInBytes - mLastLimitInBytes)
+ / TS_PACKET_SIZE,
+ TunerDebug.getVideoFrameDrop(),
+ TunerDebug.getBytesInQueue(),
+ TunerDebug.getAudioPositionUs(),
+ TunerDebug.getAudioPositionUsRate(),
+ TunerDebug.getAudioPtsUs(),
+ TunerDebug.getAudioPtsUsRate(),
+ TunerDebug.getVideoPtsUs(),
+ TunerDebug.getVideoPtsUsRate()
+ )));
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format("MSG_CHECK_SIGNAL position: %d, limit: %d",
+ positionInBytes, limitInBytes));
+ }
+ mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_MESSAGE);
+ long currentTime = SystemClock.elapsedRealtime();
+ boolean noBufferRead = positionInBytes == mLastPositionInBytes
+ && limitInBytes == mLastLimitInBytes;
+ boolean isBufferingTooLong = mBufferingStartTimeMs != INVALID_TIME
+ && currentTime - mBufferingStartTimeMs
+ > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS;
+ boolean isPreparingTooLong = mPreparingStartTimeMs != INVALID_TIME
+ && currentTime - mPreparingStartTimeMs
+ > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS;
+ boolean isWeakSignal = source != null
+ && mChannel.getType() == Channel.TYPE_TUNER
+ && (noBufferRead || isBufferingTooLong || isPreparingTooLong);
+ if (isWeakSignal && !mReportedWeakSignal) {
+ if (!mHandler.hasMessages(MSG_RETRY_PLAYBACK)) {
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(
+ MSG_RETRY_PLAYBACK, mPlayer), PLAYBACK_RETRY_DELAY_MS);
+ }
+ if (mPlayer != null) {
+ mPlayer.setAudioTrack(false);
+ }
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+ } else if (!isWeakSignal && mReportedWeakSignal) {
+ boolean isPlaybackStable = mReadyStartTimeMs != INVALID_TIME
+ && currentTime - mReadyStartTimeMs
+ > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS;
+ if (!isPlaybackStable) {
+ // Wait until playback becomes stable.
+ } else if (mReportedDrawnToSurface) {
+ mHandler.removeMessages(MSG_RETRY_PLAYBACK);
+ notifyVideoAvailable();
+ mPlayer.setAudioTrack(true);
+ }
+ }
+ mLastLimitInBytes = limitInBytes;
+ mLastPositionInBytes = positionInBytes;
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_PERIOD_MS);
+ return true;
+ }
+ case MSG_SET_SURFACE: {
+ if (mPlayer != null) {
+ mPlayer.setSurface(mSurface);
+ } else {
+ // TODO: Since surface is dynamically set, we can remove the dependency of
+ // playback start on mSurface nullity.
+ resetPlayback();
+ }
+ return true;
+ }
+ case MSG_NOTIFY_AUDIO_TRACK_UPDATED: {
+ notifyAudioTracksUpdated();
+ return true;
+ }
+ default: {
+ Log.w(TAG, "Unhandled message code: " + msg.what);
+ return false;
+ }
+ }
+ }
+
+ // Private methods
+ private void doSelectTrack(int type, String trackId) {
+ int numTrackId = trackId != null
+ ? Integer.parseInt(trackId.substring(TRACK_PREFIX_SIZE)) : -1;
+ if (type == TvTrackInfo.TYPE_AUDIO) {
+ if (trackId == null) {
+ return;
+ }
+ AtscAudioTrack audioTrack = mAudioTrackMap.get(numTrackId);
+ if (audioTrack == null) {
+ return;
+ }
+ int oldAudioPid = mChannel.getAudioPid();
+ mChannel.selectAudioTrack(audioTrack.index);
+ int newAudioPid = mChannel.getAudioPid();
+ if (oldAudioPid != newAudioPid) {
+ mPlayer.setSelectedTrack(MpegTsPlayer.TRACK_TYPE_AUDIO, audioTrack.index);
+ }
+ mSession.notifyTrackSelected(type, trackId);
+ } else if (type == TvTrackInfo.TYPE_SUBTITLE) {
+ if (trackId == null) {
+ mSession.notifyTrackSelected(type, null);
+ mCaptionTrack = null;
+ stopCaptionTrack();
+ return;
+ }
+ for (TvTrackInfo track : mTvTracks) {
+ if (track.getId().equals(trackId)) {
+ // The service number of the caption service is used for track id of a
+ // subtitle track. Passes the following track id on to TsParser.
+ mSession.notifyTrackSelected(type, trackId);
+ mCaptionTrack = mCaptionTrackMap.get(numTrackId);
+ startCaptionTrack();
+ return;
+ }
+ }
+ }
+ }
+
+ private MpegTsPlayer createPlayer(AudioCapabilities capabilities, BufferManager bufferManager) {
+ if (capabilities == null) {
+ Log.w(TAG, "No Audio Capabilities");
+ }
+
+ MpegTsPlayer player = new MpegTsPlayer(
+ new MpegTsRendererBuilder(mContext, bufferManager, this),
+ mHandler, mSourceManager, capabilities, this);
+ Log.i(TAG, "Passthrough AC3 renderer");
+ if (DEBUG) Log.d(TAG, "ExoPlayer created");
+ return player;
+ }
+
+ private void startCaptionTrack() {
+ if (mCaptionEnabled && mCaptionTrack != null) {
+ mSession.sendUiMessage(
+ TunerSession.MSG_UI_START_CAPTION_TRACK, mCaptionTrack);
+ if (mPlayer != null) {
+ mPlayer.setCaptionServiceNumber(mCaptionTrack.serviceNumber);
+ }
+ }
+ }
+
+ private void stopCaptionTrack() {
+ if (mPlayer != null) {
+ mPlayer.setCaptionServiceNumber(Cea708Data.EMPTY_SERVICE_NUMBER);
+ }
+ mSession.sendUiMessage(TunerSession.MSG_UI_STOP_CAPTION_TRACK);
+ }
+
+ private void resetTvTracks() {
+ mTvTracks.clear();
+ mAudioTrackMap.clear();
+ mCaptionTrackMap.clear();
+ mSession.sendUiMessage(TunerSession.MSG_UI_RESET_CAPTION_TRACK);
+ mSession.notifyTracksChanged(mTvTracks);
+ }
+
+ private void updateTvTracks(TvTracksInterface tvTracksInterface, boolean fromPmt) {
+ if (DEBUG) {
+ Log.d(TAG, "UpdateTvTracks " + tvTracksInterface);
+ }
+ List<AtscAudioTrack> audioTracks = tvTracksInterface.getAudioTracks();
+ List<AtscCaptionTrack> captionTracks = tvTracksInterface.getCaptionTracks();
+ // According to ATSC A/69 chapter 6.9, both PMT and EIT should have descriptors for audio
+ // tracks, but in real world, we see some bogus audio track info in EIT, so, we trust audio
+ // track info in PMT more and use info in EIT only when we have nothing.
+ if (audioTracks != null && !audioTracks.isEmpty()
+ && (mChannel.getAudioTracks() == null || fromPmt)) {
+ updateAudioTracks(audioTracks);
+ }
+ if (captionTracks == null || captionTracks.isEmpty()) {
+ if (tvTracksInterface.hasCaptionTrack()) {
+ updateCaptionTracks(captionTracks);
+ }
+ } else {
+ updateCaptionTracks(captionTracks);
+ }
+ }
+
+ private void removeTvTracks(int trackType) {
+ Iterator<TvTrackInfo> iterator = mTvTracks.iterator();
+ while (iterator.hasNext()) {
+ TvTrackInfo tvTrackInfo = iterator.next();
+ if (tvTrackInfo.getType() == trackType) {
+ iterator.remove();
+ }
+ }
+ }
+
+ private void updateVideoTrack(int width, int height) {
+ removeTvTracks(TvTrackInfo.TYPE_VIDEO);
+ mTvTracks.add(new TvTrackInfo.Builder(TvTrackInfo.TYPE_VIDEO, VIDEO_TRACK_ID)
+ .setVideoWidth(width).setVideoHeight(height).build());
+ mSession.notifyTracksChanged(mTvTracks);
+ mSession.notifyTrackSelected(TvTrackInfo.TYPE_VIDEO, VIDEO_TRACK_ID);
+ }
+
+ private void updateAudioTracks(List<AtscAudioTrack> audioTracks) {
+ if (DEBUG) {
+ Log.d(TAG, "Update AudioTracks " + audioTracks);
+ }
+ mAudioTrackMap.clear();
+ if (audioTracks != null) {
+ int index = 0;
+ for (AtscAudioTrack audioTrack : audioTracks) {
+ audioTrack.index = index;
+ mAudioTrackMap.put(index, audioTrack);
+ ++index;
+ }
+ }
+ mHandler.sendEmptyMessage(MSG_NOTIFY_AUDIO_TRACK_UPDATED);
+ }
+
+ private void notifyAudioTracksUpdated() {
+ if (mPlayer == null) {
+ // Audio tracks will be updated later once player initialization is done.
+ return;
+ }
+ int audioTrackCount = mPlayer.getTrackCount(MpegTsPlayer.TRACK_TYPE_AUDIO);
+ removeTvTracks(TvTrackInfo.TYPE_AUDIO);
+ for (int i = 0; i < audioTrackCount; i++) {
+ AtscAudioTrack audioTrack = mAudioTrackMap.get(i);
+ if (audioTrack == null) {
+ continue;
+ }
+ String language = audioTrack.language;
+ if (language == null && mChannel.getAudioTracks() != null
+ && mChannel.getAudioTracks().size() == mAudioTrackMap.size()) {
+ // If a language is not present, use a language field in PMT section parsed.
+ language = mChannel.getAudioTracks().get(i).language;
+ }
+ // Save the index to the audio track.
+ // Later, when an audio track is selected, both the audio pid and its audio stream
+ // type reside in the selected index position of the tuner channel's audio data.
+ audioTrack.index = i;
+ TvTrackInfo.Builder builder = new TvTrackInfo.Builder(
+ TvTrackInfo.TYPE_AUDIO, AUDIO_TRACK_PREFIX + i);
+ builder.setLanguage(language);
+ builder.setAudioChannelCount(audioTrack.channelCount);
+ builder.setAudioSampleRate(audioTrack.sampleRate);
+ TvTrackInfo track = builder.build();
+ mTvTracks.add(track);
+ }
+ mSession.notifyTracksChanged(mTvTracks);
+ }
+
+ private void updateCaptionTracks(List<AtscCaptionTrack> captionTracks) {
+ if (DEBUG) {
+ Log.d(TAG, "Update CaptionTrack " + captionTracks);
+ }
+ removeTvTracks(TvTrackInfo.TYPE_SUBTITLE);
+ mCaptionTrackMap.clear();
+ if (captionTracks != null) {
+ for (AtscCaptionTrack captionTrack : captionTracks) {
+ if (mCaptionTrackMap.indexOfKey(captionTrack.serviceNumber) >= 0) {
+ continue;
+ }
+ String language = captionTrack.language;
+
+ // The service number of the caption service is used for track id of a subtitle.
+ // Later, when a subtitle is chosen, track id will be passed on to TsParser.
+ TvTrackInfo.Builder builder =
+ new TvTrackInfo.Builder(TvTrackInfo.TYPE_SUBTITLE,
+ SUBTITLE_TRACK_PREFIX + captionTrack.serviceNumber);
+ builder.setLanguage(language);
+ mTvTracks.add(builder.build());
+ mCaptionTrackMap.put(captionTrack.serviceNumber, captionTrack);
+ }
+ }
+ mSession.notifyTracksChanged(mTvTracks);
+ }
+
+ private void updateChannelInfo(TunerChannel channel) {
+ if (DEBUG) {
+ Log.d(TAG, String.format("Channel Info (old) videoPid: %d audioPid: %d " +
+ "audioSize: %d", mChannel.getVideoPid(), mChannel.getAudioPid(),
+ mChannel.getAudioPids().size()));
+ }
+
+ // The list of the audio tracks resided in a channel is often changed depending on a
+ // program being on the air. So, we should update the streaming PIDs and types of the
+ // tuned channel according to the newly received channel data.
+ int oldVideoPid = mChannel.getVideoPid();
+ int oldAudioPid = mChannel.getAudioPid();
+ List<Integer> audioPids = channel.getAudioPids();
+ List<Integer> audioStreamTypes = channel.getAudioStreamTypes();
+ int size = audioPids.size();
+ mChannel.setVideoPid(channel.getVideoPid());
+ mChannel.setAudioPids(audioPids);
+ mChannel.setAudioStreamTypes(audioStreamTypes);
+ updateTvTracks(channel, true);
+ int index = audioPids.isEmpty() ? -1 : 0;
+ for (int i = 0; i < size; ++i) {
+ if (audioPids.get(i) == oldAudioPid) {
+ index = i;
+ break;
+ }
+ }
+ mChannel.selectAudioTrack(index);
+ mSession.notifyTrackSelected(TvTrackInfo.TYPE_AUDIO,
+ index == -1 ? null : AUDIO_TRACK_PREFIX + index);
+
+ // Reset playback if there is a change in the listening streaming PIDs.
+ if (oldVideoPid != mChannel.getVideoPid()
+ || oldAudioPid != mChannel.getAudioPid()) {
+ // TODO: Implement a switching between tracks more smoothly.
+ resetPlayback();
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format("Channel Info (new) videoPid: %d audioPid: %d " +
+ " audioSize: %d", mChannel.getVideoPid(), mChannel.getAudioPid(),
+ mChannel.getAudioPids().size()));
+ }
+ }
+
+ private void stopPlayback() {
+ mChannelDataManager.removeAllCallbacksAndMessages();
+ if (mPlayer != null) {
+ mPlayer.setPlayWhenReady(false);
+ mPlayer.release();
+ mPlayer = null;
+ mPlayerState = ExoPlayer.STATE_IDLE;
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayerStarted = false;
+ mReportedDrawnToSurface = false;
+ mPreparingStartTimeMs = INVALID_TIME;
+ mBufferingStartTimeMs = INVALID_TIME;
+ mReadyStartTimeMs = INVALID_TIME;
+ mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_AUDIO_UNPLAYABLE);
+ mSession.notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE);
+ }
+ }
+
+ private void startPlayback(Object playerObj) {
+ // TODO: provide hasAudio()/hasVideo() for play recordings.
+ if (mPlayer == null || mPlayer != playerObj) {
+ return;
+ }
+ if (mChannel != null && !mChannel.hasAudio()) {
+ if (DEBUG) Log.d(TAG, "Channel " + mChannel + " does not have audio.");
+ // Playbacks with video-only stream have not been tested yet.
+ // No video-only channel has been found.
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
+ return;
+ }
+ if (mChannel != null && ((mChannel.hasAudio() && !mPlayer.hasAudio())
+ || (mChannel.hasVideo() && !mPlayer.hasVideo()))) {
+ // Tracks haven't been detected in the extractor. Try again.
+ sendMessage(MSG_RETRY_PLAYBACK, mPlayer);
+ return;
+ }
+ // Since mSurface is volatile, we define a local variable surface to keep the same value
+ // inside this method.
+ Surface surface = mSurface;
+ if (surface != null && !mPlayerStarted) {
+ mPlayer.setSurface(surface);
+ mPlayer.setPlayWhenReady(true);
+ mPlayer.setVolume(mVolume);
+ if (mChannel != null && !mChannel.hasVideo() && mChannel.hasAudio()) {
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY);
+ } else if (!mReportedWeakSignal) {
+ // Doesn't show buffering during weak signal.
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING);
+ }
+ mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_MESSAGE);
+ mPlayerStarted = true;
+ }
+ }
+
+ private void preparePlayback() {
+ SoftPreconditions.checkState(mPlayer == null);
+ if (mChannel == null && mRecordingId == null) {
+ return;
+ }
+ mSourceManager.setKeepTuneStatus(true);
+ BufferManager bufferManager = mChannel != null ? mBufferManager : new BufferManager(
+ new DvrStorageManager(new File(getRecordingPath()), false));
+ MpegTsPlayer player = createPlayer(mAudioCapabilities, bufferManager);
+ player.setCaptionServiceNumber(Cea708Data.EMPTY_SERVICE_NUMBER);
+ player.setVideoEventListener(this);
+ player.setCaptionServiceNumber(mCaptionTrack != null ?
+ mCaptionTrack.serviceNumber : Cea708Data.EMPTY_SERVICE_NUMBER);
+ if (!player.prepare(mContext, mChannel, this)) {
+ mSourceManager.setKeepTuneStatus(false);
+ player.release();
+ if (!mHandler.hasMessages(MSG_TUNE)) {
+ // When prepare failed, there may be some errors related to hardware. In that
+ // case, retry playback immediately may not help.
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RETRY_PLAYBACK, mPlayer),
+ PLAYBACK_RETRY_DELAY_MS);
+ }
+ } else {
+ mPlayer = player;
+ mPlayerStarted = false;
+ mHandler.removeMessages(MSG_CHECK_SIGNAL);
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_INITIAL_DELAY_MS);
+ }
+ }
+
+ private void resetPlayback() {
+ long timestamp, oldTimestamp;
+ timestamp = SystemClock.elapsedRealtime();
+ stopPlayback();
+ stopCaptionTrack();
+ if (ENABLE_PROFILER) {
+ oldTimestamp = timestamp;
+ timestamp = SystemClock.elapsedRealtime();
+ Log.i(TAG, "[Profiler] stopPlayback() takes " + (timestamp - oldTimestamp) + " ms");
+ }
+ if (mChannelBlocked || mSurface == null) {
+ return;
+ }
+ preparePlayback();
+ }
+
+ private void prepareTune(TunerChannel channel, String recording) {
+ mChannelBlocked = false;
+ mUnblockedContentRating = null;
+ mRetryCount = 0;
+ mChannel = channel;
+ mRecordingId = recording;
+ mRecordingDuration = recording != null ? getDurationForRecording(recording) : null;
+ mProgram = null;
+ mPrograms = null;
+ mBufferStartTimeMs = mRecordStartTimeMs =
+ (mRecordingId != null) ? 0 : System.currentTimeMillis();
+ mLastPositionMs = 0;
+ mCaptionTrack = null;
+ mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS);
+ }
+
+ private void doReschedulePrograms() {
+ long currentPositionMs = getCurrentPosition();
+ long forwardDifference = Math.abs(currentPositionMs - mLastPositionMs
+ - RESCHEDULE_PROGRAMS_INTERVAL_MS);
+ mLastPositionMs = currentPositionMs;
+
+ // A gap is measured as the time difference between previous and next current position
+ // periodically. If the gap has a significant difference with an interval of a period,
+ // this means that there is a change of playback status and the programs of the current
+ // channel should be rescheduled to new playback timeline.
+ if (forwardDifference > RESCHEDULE_PROGRAMS_TOLERANCE_MS) {
+ if (DEBUG) {
+ Log.d(TAG, "reschedule programs size:"
+ + (mPrograms != null ? mPrograms.size() : 0) + " current program: "
+ + getCurrentProgram());
+ }
+ mHandler.obtainMessage(MSG_SCHEDULE_OF_PROGRAMS, new Pair<>(mChannel, mPrograms))
+ .sendToTarget();
+ }
+ mHandler.removeMessages(MSG_RESCHEDULE_PROGRAMS);
+ mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS,
+ RESCHEDULE_PROGRAMS_INTERVAL_MS);
+ }
+
+ private int getTrickPlaySeekIntervalMs() {
+ return Math.max(EXPECTED_KEY_FRAME_INTERVAL_MS / (int) Math.abs(mPlaybackParams.getSpeed()),
+ MIN_TRICKPLAY_SEEK_INTERVAL_MS);
+ }
+
+ private void doTrickplayBySeek(int seekPositionMs) {
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ if (mPlaybackParams.getSpeed() == 1.0f || !mPlayer.isPrepared()) {
+ return;
+ }
+ if (seekPositionMs < mBufferStartTimeMs - mRecordStartTimeMs) {
+ if (mPlaybackParams.getSpeed() > 1.0f) {
+ // If fast forwarding, the seekPositionMs can be out of the buffered range
+ // because of chuck evictions.
+ seekPositionMs = (int) (mBufferStartTimeMs - mRecordStartTimeMs);
+ } else {
+ mPlayer.seekTo(mBufferStartTimeMs - mRecordStartTimeMs);
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setAudioTrack(true);
+ return;
+ }
+ } else if (seekPositionMs > System.currentTimeMillis() - mRecordStartTimeMs) {
+ mPlayer.seekTo(System.currentTimeMillis() - mRecordStartTimeMs);
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setAudioTrack(true);
+ return;
+ }
+
+ long delayForNextSeek = getTrickPlaySeekIntervalMs();
+ if (!mPlayer.isBuffering()) {
+ mPlayer.seekTo(seekPositionMs);
+ } else {
+ delayForNextSeek = MIN_TRICKPLAY_SEEK_INTERVAL_MS;
+ }
+ seekPositionMs += mPlaybackParams.getSpeed() * delayForNextSeek;
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(
+ MSG_TRICKPLAY_BY_SEEK, seekPositionMs, 0), delayForNextSeek);
+ }
+
+ private void doTimeShiftPause() {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ if (!hasEnoughBackwardBuffer()) {
+ return;
+ }
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setPlayWhenReady(false);
+ mPlayer.setAudioTrack(true);
+ }
+
+ private void doTimeShiftResume() {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setPlayWhenReady(true);
+ mPlayer.setAudioTrack(true);
+ }
+
+ private void doTimeShiftSeekTo(long timeMs) {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ mPlayer.seekTo((int) (timeMs - mRecordStartTimeMs));
+ }
+
+ private void doTimeShiftSetPlaybackParams(PlaybackParams params) {
+ if (!hasEnoughBackwardBuffer() && params.getSpeed() < 1.0f) {
+ return;
+ }
+ mPlaybackParams = params;
+ float speed = mPlaybackParams.getSpeed();
+ if (speed == 1.0f) {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ doTimeShiftResume();
+ } else if (mPlayer.supportSmoothTrickPlay(speed)) {
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ mPlayer.setAudioTrack(false);
+ mPlayer.startSmoothTrickplay(mPlaybackParams);
+ mHandler.sendEmptyMessageDelayed(MSG_SMOOTH_TRICKPLAY_MONITOR,
+ TRICKPLAY_MONITOR_INTERVAL_MS);
+ } else {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ if (!mHandler.hasMessages(MSG_TRICKPLAY_BY_SEEK)) {
+ mPlayer.setAudioTrack(false);
+ mPlayer.setPlayWhenReady(false);
+ // Initiate trickplay
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_TRICKPLAY_BY_SEEK,
+ (int) (mPlayer.getCurrentPosition()
+ + speed * getTrickPlaySeekIntervalMs()), 0));
+ }
+ }
+ }
+
+ private EitItem getCurrentProgram() {
+ if (mPrograms == null || mPrograms.isEmpty()) {
+ return null;
+ }
+ if (mChannel.getType() == Channel.TYPE_FILE) {
+ // For the playback from the local file, we use the first one from the given program.
+ EitItem first = mPrograms.get(0);
+ if (first != null && (mProgram == null
+ || first.getStartTimeUtcMillis() < mProgram.getStartTimeUtcMillis())) {
+ return first;
+ }
+ return null;
+ }
+ long currentTimeMs = getCurrentPosition();
+ for (EitItem item : mPrograms) {
+ if (item.getStartTimeUtcMillis() <= currentTimeMs
+ && item.getEndTimeUtcMillis() >= currentTimeMs) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ private void doParentalControls() {
+ boolean isParentalControlsEnabled = mTvInputManager.isParentalControlsEnabled();
+ if (isParentalControlsEnabled) {
+ TvContentRating blockContentRating = getContentRatingOfCurrentProgramBlocked();
+ if (DEBUG) {
+ if (blockContentRating != null) {
+ Log.d(TAG, "Check parental controls: blocked by content rating - "
+ + blockContentRating);
+ } else {
+ Log.d(TAG, "Check parental controls: available");
+ }
+ }
+ updateChannelBlockStatus(blockContentRating != null, blockContentRating);
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "Check parental controls: available");
+ }
+ updateChannelBlockStatus(false, null);
+ }
+ }
+
+ private void doDiscoverCaptionServiceNumber(int serviceNumber) {
+ int index = mCaptionTrackMap.indexOfKey(serviceNumber);
+ if (index < 0) {
+ AtscCaptionTrack captionTrack = new AtscCaptionTrack();
+ captionTrack.serviceNumber = serviceNumber;
+ captionTrack.wideAspectRatio = false;
+ captionTrack.easyReader = false;
+ mCaptionTrackMap.put(serviceNumber, captionTrack);
+ mTvTracks.add(new TvTrackInfo.Builder(TvTrackInfo.TYPE_SUBTITLE,
+ SUBTITLE_TRACK_PREFIX + serviceNumber).build());
+ mSession.notifyTracksChanged(mTvTracks);
+ }
+ }
+
+ private TvContentRating getContentRatingOfCurrentProgramBlocked() {
+ EitItem currentProgram = getCurrentProgram();
+ if (currentProgram == null) {
+ return null;
+ }
+ TvContentRating[] ratings = mTvContentRatingCache
+ .getRatings(currentProgram.getContentRating());
+ if (ratings == null) {
+ return null;
+ }
+ for (TvContentRating rating : ratings) {
+ if (!Objects.equals(mUnblockedContentRating, rating) && mTvInputManager
+ .isRatingBlocked(rating)) {
+ return rating;
+ }
+ }
+ return null;
+ }
+
+ private void updateChannelBlockStatus(boolean channelBlocked,
+ TvContentRating contentRating) {
+ if (mChannelBlocked == channelBlocked) {
+ return;
+ }
+ mChannelBlocked = channelBlocked;
+ if (mChannelBlocked) {
+ mHandler.removeCallbacksAndMessages(null);
+ stopPlayback();
+ resetTvTracks();
+ if (contentRating != null) {
+ mSession.notifyContentBlocked(contentRating);
+ }
+ mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS, PARENTAL_CONTROLS_INTERVAL_MS);
+ } else {
+ mHandler.removeCallbacksAndMessages(null);
+ resetPlayback();
+ mSession.notifyContentAllowed();
+ mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS,
+ RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS);
+ mHandler.removeMessages(MSG_CHECK_SIGNAL);
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_INITIAL_DELAY_MS);
+ }
+ }
+
+ private boolean hasEnoughBackwardBuffer() {
+ return mPlayer.getCurrentPosition() + BUFFER_UNDERFLOW_BUFFER_MS
+ >= mBufferStartTimeMs - mRecordStartTimeMs;
+ }
+
+ private void notifyVideoUnavailable(final int reason) {
+ mReportedWeakSignal = (reason == TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+ if (mSession != null) {
+ mSession.notifyVideoUnavailable(reason);
+ }
+ }
+
+ private void notifyVideoAvailable() {
+ mReportedWeakSignal = false;
+ if (mSession != null) {
+ mSession.notifyVideoAvailable();
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java
new file mode 100644
index 00000000..e734b779
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java
@@ -0,0 +1,166 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.app.job.JobParameters;
+import android.app.job.JobService;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.os.AsyncTask;
+
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrStorageStatusManager;
+import com.android.tv.util.Utils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Creates {@link JobService} to clean up recorded program files which are not referenced
+ * from database.
+ */
+public class TunerStorageCleanUpService extends JobService {
+ private CleanUpStorageTask mTask;
+
+ @Override
+ public void onCreate() {
+ TvApplication.setCurrentRunningProcess(this, false);
+ super.onCreate();
+ mTask = new CleanUpStorageTask(this, this);
+ }
+
+ @Override
+ public boolean onStartJob(JobParameters params) {
+ mTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, params);
+ return true;
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters params) {
+ return false;
+ }
+
+ /**
+ * Cleans up recorded program files which are not referenced from database.
+ * Cleaning up will be done periodically.
+ */
+ public static class CleanUpStorageTask extends AsyncTask<JobParameters, Void, JobParameters[]> {
+ private final static String[] mProjection = {
+ TvContract.RecordedPrograms.COLUMN_PACKAGE_NAME,
+ TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI
+ };
+ private final static long ELAPSED_MILLIS_TO_DELETE = TimeUnit.DAYS.toMillis(1);
+
+ private final Context mContext;
+ private final DvrStorageStatusManager mDvrStorageStatusManager;
+ private final JobService mJobService;
+ private final ContentResolver mContentResolver;
+
+ /**
+ * Creates a recurring storage cleaning task.
+ *
+ * @param context {@link Context}
+ * @param jobService {@link JobService}
+ */
+ public CleanUpStorageTask(Context context, JobService jobService) {
+ mContext = context;
+ mDvrStorageStatusManager =
+ TvApplication.getSingletons(mContext).getDvrStorageStatusManager();
+ mJobService = jobService;
+ mContentResolver = mContext.getContentResolver();
+ }
+
+ private Set<String> getRecordedProgramsDirs() {
+ try (Cursor c = mContentResolver.query(
+ TvContract.RecordedPrograms.CONTENT_URI, mProjection, null, null, null)) {
+ if (c == null) {
+ return null;
+ }
+ Set<String> recordedProgramDirs = new HashSet<>();
+ while (c.moveToNext()) {
+ String packageName = c.getString(0);
+ String dataUriString = c.getString(1);
+ if (dataUriString == null) {
+ continue;
+ }
+ Uri dataUri = Uri.parse(dataUriString);
+ if (!Utils.isInBundledPackageSet(packageName)
+ || dataUri == null || dataUri.getPath() == null
+ || !ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())) {
+ continue;
+ }
+ File recordedProgramDir = new File(dataUri.getPath());
+ try {
+ recordedProgramDirs.add(recordedProgramDir.getCanonicalPath());
+ } catch (IOException | SecurityException e) {
+ }
+ }
+ return recordedProgramDirs;
+ }
+ }
+
+ @Override
+ protected JobParameters[] doInBackground(JobParameters... params) {
+ if (mDvrStorageStatusManager.getDvrStorageStatus()
+ == DvrStorageStatusManager.STORAGE_STATUS_MISSING) {
+ return params;
+ }
+ File dvrRecordingDir = mDvrStorageStatusManager.getRecordingRootDataDirectory();
+ if (dvrRecordingDir == null || !dvrRecordingDir.isDirectory()) {
+ return params;
+ }
+ Set<String> recordedProgramDirs = getRecordedProgramsDirs();
+ if (recordedProgramDirs == null) {
+ return params;
+ }
+ File[] files = dvrRecordingDir.listFiles();
+ if (files == null || files.length == 0) {
+ return params;
+ }
+ for (File recordingDir : files) {
+ try {
+ if (!recordedProgramDirs.contains(recordingDir.getCanonicalPath())) {
+ long lastModified = recordingDir.lastModified();
+ long now = System.currentTimeMillis();
+ if (lastModified != 0
+ && lastModified < now - ELAPSED_MILLIS_TO_DELETE) {
+ // To prevent current recordings from being deleted,
+ // deletes recordings which was not modified for long enough time.
+ Utils.deleteDirOrFile(recordingDir);
+ }
+ }
+ } catch (IOException | SecurityException e) {
+ // would not happen
+ }
+ }
+ return params;
+ }
+
+ @Override
+ protected void onPostExecute(JobParameters[] params) {
+ for (JobParameters param : params) {
+ mJobService.jobFinished(param, false);
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerTvInputService.java b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java
new file mode 100644
index 00000000..684ebdbd
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java
@@ -0,0 +1,146 @@
+/*
+ * 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.tuner.tvinput;
+
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
+import android.content.ComponentName;
+import android.content.Context;
+import android.media.tv.TvContract;
+import android.media.tv.TvInputService;
+import android.util.Log;
+
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.google.android.exoplayer.audio.AudioCapabilitiesReceiver;
+import com.android.tv.TvApplication;
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.TrickplayStorageManager;
+import com.android.tv.tuner.util.SystemPropertiesProxy;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * {@link TunerTvInputService} serves TV channels coming from a tuner device.
+ */
+public class TunerTvInputService extends TvInputService
+ implements AudioCapabilitiesReceiver.Listener{
+ private static final String TAG = "TunerTvInputService";
+ private static final boolean DEBUG = false;
+
+ private static final String MAX_BUFFER_SIZE_KEY = "tv.tuner.buffersize_mbytes";
+ private static final int MAX_BUFFER_SIZE_DEF = 2 * 1024; // 2GB
+ private static final int MIN_BUFFER_SIZE_DEF = 256; // 256MB
+ private static final int DVR_STORAGE_CLEANUP_JOB_ID = 100;
+
+ // WeakContainer for {@link TvInputSessionImpl}
+ private final Set<TunerSession> mTunerSessions = Collections.newSetFromMap(new WeakHashMap<>());
+ private ChannelDataManager mChannelDataManager;
+ private AudioCapabilitiesReceiver mAudioCapabilitiesReceiver;
+ private AudioCapabilities mAudioCapabilities;
+ private BufferManager mBufferManager;
+
+ @Override
+ public void onCreate() {
+ TvApplication.setCurrentRunningProcess(this, false);
+ super.onCreate();
+ if (DEBUG) Log.d(TAG, "onCreate");
+ mChannelDataManager = new ChannelDataManager(getApplicationContext());
+ mAudioCapabilitiesReceiver = new AudioCapabilitiesReceiver(getApplicationContext(), this);
+ mAudioCapabilitiesReceiver.register();
+ mBufferManager = createBufferManager();
+ if (CommonFeatures.DVR.isEnabled(this)) {
+ JobScheduler jobScheduler =
+ (JobScheduler) getSystemService(Context.JOB_SCHEDULER_SERVICE);
+ JobInfo pendingJob = jobScheduler.getPendingJob(DVR_STORAGE_CLEANUP_JOB_ID);
+ if (pendingJob != null) {
+ // storage cleaning job is already scheduled.
+ } else {
+ JobInfo job = new JobInfo.Builder(DVR_STORAGE_CLEANUP_JOB_ID,
+ new ComponentName(this, TunerStorageCleanUpService.class))
+ .setPersisted(true).setPeriodic(TimeUnit.DAYS.toMillis(1)).build();
+ jobScheduler.schedule(job);
+ }
+ }
+ if (mBufferManager == null) {
+ Log.i(TAG, "Trickplay is disabled");
+ } else {
+ Log.i(TAG, "Trickplay is enabled");
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (DEBUG) Log.d(TAG, "onDestroy");
+ super.onDestroy();
+ mChannelDataManager.release();
+ mAudioCapabilitiesReceiver.unregister();
+ if (mBufferManager != null) {
+ mBufferManager.close();
+ }
+ }
+
+ @Override
+ public RecordingSession onCreateRecordingSession(String inputId) {
+ return new TunerRecordingSession(this, inputId, mChannelDataManager);
+ }
+
+ @Override
+ public Session onCreateSession(String inputId) {
+ if (DEBUG) Log.d(TAG, "onCreateSession");
+ try {
+ final TunerSession session = new TunerSession(
+ this, mChannelDataManager, mBufferManager);
+ mTunerSessions.add(session);
+ session.setAudioCapabilities(mAudioCapabilities);
+ session.setOverlayViewEnabled(true);
+ return session;
+ } catch (RuntimeException e) {
+ // There are no available DVB devices.
+ Log.e(TAG, "Creating a session for " + inputId + " failed.", e);
+ return null;
+ }
+ }
+
+ @Override
+ public void onAudioCapabilitiesChanged(AudioCapabilities audioCapabilities) {
+ mAudioCapabilities = audioCapabilities;
+ for (TunerSession session : mTunerSessions) {
+ if (!session.isReleased()) {
+ session.setAudioCapabilities(audioCapabilities);
+ }
+ }
+ }
+
+ private BufferManager createBufferManager() {
+ int maxBufferSizeMb =
+ SystemPropertiesProxy.getInt(MAX_BUFFER_SIZE_KEY, MAX_BUFFER_SIZE_DEF);
+ if (maxBufferSizeMb >= MIN_BUFFER_SIZE_DEF) {
+ return new BufferManager(
+ new TrickplayStorageManager(getApplicationContext(), getCacheDir(),
+ 1024L * 1024 * maxBufferSizeMb));
+ }
+ return null;
+ }
+
+ public static String getInputId(Context context) {
+ return TvContract.buildInputId(new ComponentName(context, TunerTvInputService.class));
+ }
+}