diff options
Diffstat (limited to 'src/com/android/tv/tuner/tvinput')
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)); + } +} |