aboutsummaryrefslogtreecommitdiff
path: root/tuner/src/com
diff options
context:
space:
mode:
Diffstat (limited to 'tuner/src/com')
-rw-r--r--tuner/src/com/android/tv/tuner/ChannelScanFileParser.java108
-rw-r--r--tuner/src/com/android/tv/tuner/DvbDeviceAccessor.java222
-rw-r--r--tuner/src/com/android/tv/tuner/DvbTunerHal.java177
-rw-r--r--tuner/src/com/android/tv/tuner/TunerFeatures.java103
-rw-r--r--tuner/src/com/android/tv/tuner/TunerHal.java369
-rw-r--r--tuner/src/com/android/tv/tuner/TunerPreferences.java100
-rw-r--r--tuner/src/com/android/tv/tuner/cc/CaptionLayout.java77
-rw-r--r--tuner/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java339
-rw-r--r--tuner/src/com/android/tv/tuner/cc/CaptionWindowLayout.java680
-rw-r--r--tuner/src/com/android/tv/tuner/cc/Cea708Parser.java922
-rw-r--r--tuner/src/com/android/tv/tuner/data/Cea708Data.java329
-rw-r--r--tuner/src/com/android/tv/tuner/data/PsiData.java93
-rw-r--r--tuner/src/com/android/tv/tuner/data/PsipData.java871
-rw-r--r--tuner/src/com/android/tv/tuner/data/TunerChannel.java552
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java305
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java41
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java632
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java139
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java672
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java77
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java345
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java195
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java126
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/SampleExtractor.java131
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java94
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java69
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java140
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java174
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java233
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java739
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java94
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java683
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java391
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java303
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java433
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java464
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java67
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java72
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java177
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java145
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/text/SubtitleView.java323
-rw-r--r--tuner/src/com/android/tv/tuner/layout/ScaledLayout.java290
-rw-r--r--tuner/src/com/android/tv/tuner/livetuner/LiveTvTunerTvInputService.java22
-rw-r--r--tuner/src/com/android/tv/tuner/setup/BaseTunerSetupActivity.java516
-rw-r--r--tuner/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java101
-rw-r--r--tuner/src/com/android/tv/tuner/setup/LineupFragment.java235
-rw-r--r--tuner/src/com/android/tv/tuner/setup/LiveTvTunerSetupActivity.java75
-rw-r--r--tuner/src/com/android/tv/tuner/setup/PostalCodeFragment.java186
-rw-r--r--tuner/src/com/android/tv/tuner/setup/ScanFragment.java553
-rw-r--r--tuner/src/com/android/tv/tuner/setup/ScanResultFragment.java134
-rw-r--r--tuner/src/com/android/tv/tuner/setup/WelcomeFragment.java128
-rw-r--r--tuner/src/com/android/tv/tuner/source/FileTsStreamer.java487
-rw-r--r--tuner/src/com/android/tv/tuner/source/TsDataSource.java49
-rw-r--r--tuner/src/com/android/tv/tuner/source/TsDataSourceManager.java136
-rw-r--r--tuner/src/com/android/tv/tuner/source/TsStreamWriter.java238
-rw-r--r--tuner/src/com/android/tv/tuner/source/TsStreamer.java53
-rw-r--r--tuner/src/com/android/tv/tuner/source/TunerTsStreamer.java420
-rw-r--r--tuner/src/com/android/tv/tuner/source/TunerTsStreamerManager.java303
-rw-r--r--tuner/src/com/android/tv/tuner/ts/SectionParser.java2094
-rw-r--r--tuner/src/com/android/tv/tuner/ts/TsParser.java543
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/BaseTunerTvInputService.java130
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/ChannelDataManager.java795
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/EventDetector.java349
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java259
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java38
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/TunerDebug.java147
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java101
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java583
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/TunerSession.java341
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java1863
-rw-r--r--tuner/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java176
-rw-r--r--tuner/src/com/android/tv/tuner/util/ByteArrayBuffer.java155
-rw-r--r--tuner/src/com/android/tv/tuner/util/ConvertUtils.java33
-rw-r--r--tuner/src/com/android/tv/tuner/util/GlobalSettingsUtils.java34
-rw-r--r--tuner/src/com/android/tv/tuner/util/Ints.java41
-rw-r--r--tuner/src/com/android/tv/tuner/util/StatusTextUtils.java137
-rw-r--r--tuner/src/com/android/tv/tuner/util/TunerInputInfoUtils.java112
-rw-r--r--tuner/src/com/google/android/exoplayer/MediaFormatUtil.java96
-rw-r--r--tuner/src/com/google/android/exoplayer/MediaSoftwareCodecUtil.java273
-rw-r--r--tuner/src/com/mediatek/tunerservice/IMtkTuner.aidl27
80 files changed, 24459 insertions, 0 deletions
diff --git a/tuner/src/com/android/tv/tuner/ChannelScanFileParser.java b/tuner/src/com/android/tv/tuner/ChannelScanFileParser.java
new file mode 100644
index 00000000..d2ed6c38
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/ChannelScanFileParser.java
@@ -0,0 +1,108 @@
+/*
+ * 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;
+
+import android.util.Log;
+import com.android.tv.tuner.data.nano.Channel;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Parses plain text formatted scan files, which contain the list of channels. */
+public class ChannelScanFileParser {
+ private static final String TAG = "ChannelScanFileParser";
+
+ public static final class ScanChannel {
+ public final int type;
+ public final int frequency;
+ public final String modulation;
+ public final String filename;
+ /**
+ * Radio frequency (channel) number specified at
+ * https://en.wikipedia.org/wiki/North_American_television_frequencies This can be {@code
+ * null} for cases like cable signal.
+ */
+ public final Integer radioFrequencyNumber;
+
+ public static ScanChannel forTuner(
+ int frequency, String modulation, Integer radioFrequencyNumber) {
+ return new ScanChannel(
+ Channel.TunerType.TYPE_TUNER,
+ frequency,
+ modulation,
+ null,
+ radioFrequencyNumber);
+ }
+
+ public static ScanChannel forFile(int frequency, String filename) {
+ return new ScanChannel(Channel.TunerType.TYPE_FILE, frequency, "file:", filename, null);
+ }
+
+ private ScanChannel(
+ int type,
+ int frequency,
+ String modulation,
+ String filename,
+ Integer radioFrequencyNumber) {
+ this.type = type;
+ this.frequency = frequency;
+ this.modulation = modulation;
+ this.filename = filename;
+ this.radioFrequencyNumber = radioFrequencyNumber;
+ }
+ }
+
+ /**
+ * Parses a given scan file and returns the list of {@link ScanChannel} objects.
+ *
+ * @param is {@link InputStream} of a scan file. Each line matches one channel. The line format
+ * of the scan file is as follows:<br>
+ * "A &lt;frequency&gt; &lt;modulation&gt;".
+ * @return a list of {@link ScanChannel} objects parsed
+ */
+ public static List<ScanChannel> parseScanFile(InputStream is) {
+ BufferedReader in = new BufferedReader(new InputStreamReader(is));
+ String line;
+ List<ScanChannel> scanChannelList = new ArrayList<>();
+ try {
+ while ((line = in.readLine()) != null) {
+ if (line.isEmpty()) {
+ continue;
+ }
+ if (line.charAt(0) == '#') {
+ // Skip comment line
+ continue;
+ }
+ String[] tokens = line.split("\\s+");
+ if (tokens.length != 3 && tokens.length != 4) {
+ continue;
+ }
+ scanChannelList.add(
+ ScanChannel.forTuner(
+ Integer.parseInt(tokens[1]),
+ tokens[2],
+ tokens.length == 4 ? Integer.parseInt(tokens[3]) : null));
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "error on parseScanFile()", e);
+ }
+ return scanChannelList;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/DvbDeviceAccessor.java b/tuner/src/com/android/tv/tuner/DvbDeviceAccessor.java
new file mode 100644
index 00000000..217433d2
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/DvbDeviceAccessor.java
@@ -0,0 +1,222 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.media.tv.TvInputManager;
+import android.os.ParcelFileDescriptor;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import com.android.tv.common.recording.RecordingCapability;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/** Provides with the file descriptors to access DVB device. */
+public class DvbDeviceAccessor {
+ private static final String TAG = "DvbDeviceAccessor";
+
+ @IntDef({DVB_DEVICE_DEMUX, DVB_DEVICE_DVR, DVB_DEVICE_FRONTEND})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface DvbDevice {}
+
+ public static final int DVB_DEVICE_DEMUX = 0; // TvInputManager.DVB_DEVICE_DEMUX;
+ public static final int DVB_DEVICE_DVR = 1; // TvInputManager.DVB_DEVICE_DVR;
+ public static final int DVB_DEVICE_FRONTEND = 2; // TvInputManager.DVB_DEVICE_FRONTEND;
+
+ private static Method sGetDvbDeviceListMethod;
+ private static Method sOpenDvbDeviceMethod;
+
+ private final TvInputManager mTvInputManager;
+
+ static {
+ try {
+ Class tvInputManagerClass = Class.forName("android.media.tv.TvInputManager");
+ Class dvbDeviceInfoClass = Class.forName("android.media.tv.DvbDeviceInfo");
+ sGetDvbDeviceListMethod = tvInputManagerClass.getDeclaredMethod("getDvbDeviceList");
+ sGetDvbDeviceListMethod.setAccessible(true);
+ sOpenDvbDeviceMethod =
+ tvInputManagerClass.getDeclaredMethod(
+ "openDvbDevice", dvbDeviceInfoClass, Integer.TYPE);
+ sOpenDvbDeviceMethod.setAccessible(true);
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, "Couldn't find class", e);
+ } catch (NoSuchMethodException e) {
+ Log.e(TAG, "Couldn't find method", e);
+ }
+ }
+
+ public DvbDeviceAccessor(Context context) {
+ mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
+ }
+
+ public List<DvbDeviceInfoWrapper> getDvbDeviceList() {
+ try {
+ List<DvbDeviceInfoWrapper> wrapperList = new ArrayList<>();
+ List dvbDeviceInfoList = (List) sGetDvbDeviceListMethod.invoke(mTvInputManager);
+ for (Object dvbDeviceInfo : dvbDeviceInfoList) {
+ wrapperList.add(new DvbDeviceInfoWrapper(dvbDeviceInfo));
+ }
+ Collections.sort(wrapperList);
+ return wrapperList;
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ }
+ return null;
+ }
+
+ /** Returns the number of currently connected DVB devices. */
+ public int getNumOfDvbDevices() {
+ List<DvbDeviceInfoWrapper> dvbDeviceList = getDvbDeviceList();
+ return dvbDeviceList == null ? 0 : dvbDeviceList.size();
+ }
+
+ public boolean isDvbDeviceAvailable() {
+ try {
+ List dvbDeviceInfoList = (List) sGetDvbDeviceListMethod.invoke(mTvInputManager);
+ return (!dvbDeviceInfoList.isEmpty());
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ }
+ return false;
+ }
+
+ public ParcelFileDescriptor openDvbDevice(
+ DvbDeviceInfoWrapper deviceInfo, @DvbDevice int device) {
+ try {
+ return (ParcelFileDescriptor)
+ sOpenDvbDeviceMethod.invoke(
+ mTvInputManager, deviceInfo.getDvbDeviceInfo(), device);
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ }
+ return null;
+ }
+
+ /**
+ * Returns the current recording capability for USB tuner.
+ *
+ * @param inputId the input id to use.
+ */
+ public RecordingCapability getRecordingCapability(String inputId) {
+ List<DvbDeviceInfoWrapper> deviceList = getDvbDeviceList();
+ // TODO(DVR) implement accurate capabilities and updating values when needed.
+ return RecordingCapability.builder()
+ .setInputId(inputId)
+ .setMaxConcurrentPlayingSessions(1)
+ .setMaxConcurrentTunedSessions(deviceList.size())
+ .setMaxConcurrentSessionsOfAllTypes(deviceList.size() + 1)
+ .build();
+ }
+
+ public static class DvbDeviceInfoWrapper implements Comparable<DvbDeviceInfoWrapper> {
+ private static Method sGetAdapterIdMethod;
+ private static Method sGetDeviceIdMethod;
+ private final Object mDvbDeviceInfo;
+ private final int mAdapterId;
+ private final int mDeviceId;
+ private final long mId;
+
+ static {
+ try {
+ Class dvbDeviceInfoClass = Class.forName("android.media.tv.DvbDeviceInfo");
+ sGetAdapterIdMethod = dvbDeviceInfoClass.getDeclaredMethod("getAdapterId");
+ sGetAdapterIdMethod.setAccessible(true);
+ sGetDeviceIdMethod = dvbDeviceInfoClass.getDeclaredMethod("getDeviceId");
+ sGetDeviceIdMethod.setAccessible(true);
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, "Couldn't find class", e);
+ } catch (NoSuchMethodException e) {
+ Log.e(TAG, "Couldn't find method", e);
+ }
+ }
+
+ public DvbDeviceInfoWrapper(Object dvbDeviceInfo) {
+ mDvbDeviceInfo = dvbDeviceInfo;
+ mAdapterId = initAdapterId();
+ mDeviceId = initDeviceId();
+ mId = (((long) getAdapterId()) << 32) | (getDeviceId() & 0xffffffffL);
+ }
+
+ public long getId() {
+ return mId;
+ }
+
+ public int getAdapterId() {
+ return mAdapterId;
+ }
+
+ private int initAdapterId() {
+ try {
+ return (int) sGetAdapterIdMethod.invoke(mDvbDeviceInfo);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ }
+ return -1;
+ }
+
+ public int getDeviceId() {
+ return mDeviceId;
+ }
+
+ private int initDeviceId() {
+ try {
+ return (int) sGetDeviceIdMethod.invoke(mDvbDeviceInfo);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ }
+ return -1;
+ }
+
+ public Object getDvbDeviceInfo() {
+ return mDvbDeviceInfo;
+ }
+
+ @Override
+ public int compareTo(@NonNull DvbDeviceInfoWrapper another) {
+ if (getAdapterId() != another.getAdapterId()) {
+ return getAdapterId() - another.getAdapterId();
+ }
+ return getDeviceId() - another.getDeviceId();
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ Locale.US,
+ "DvbDeviceInfo {adapterId: %d, deviceId: %d}",
+ getAdapterId(),
+ getDeviceId());
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/DvbTunerHal.java b/tuner/src/com/android/tv/tuner/DvbTunerHal.java
new file mode 100644
index 00000000..4375fc32
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/DvbTunerHal.java
@@ -0,0 +1,177 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+import com.android.tv.tuner.DvbDeviceAccessor.DvbDeviceInfoWrapper;
+import java.util.List;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+/** A class to handle a hardware Linux DVB API supported tuner device. */
+public class DvbTunerHal extends TunerHal {
+
+ private static final Object sLock = new Object();
+ // @GuardedBy("sLock")
+ private static final SortedSet<DvbDeviceInfoWrapper> sUsedDvbDevices = new TreeSet<>();
+
+ private final DvbDeviceAccessor mDvbDeviceAccessor;
+ private DvbDeviceInfoWrapper mDvbDeviceInfo;
+
+ public DvbTunerHal(Context context) {
+ super(context);
+ mDvbDeviceAccessor = new DvbDeviceAccessor(context);
+ }
+
+ @Override
+ protected boolean openFirstAvailable() {
+ List<DvbDeviceInfoWrapper> deviceInfoList = mDvbDeviceAccessor.getDvbDeviceList();
+ if (deviceInfoList == null || deviceInfoList.isEmpty()) {
+ Log.e(TAG, "There's no dvb device attached");
+ return false;
+ }
+ synchronized (sLock) {
+ for (DvbDeviceInfoWrapper deviceInfo : deviceInfoList) {
+ if (!sUsedDvbDevices.contains(deviceInfo)) {
+ if (DEBUG) Log.d(TAG, "Available device info: " + deviceInfo);
+ mDvbDeviceInfo = deviceInfo;
+ sUsedDvbDevices.add(deviceInfo);
+ getDeliverySystemTypeFromDevice();
+ return true;
+ }
+ }
+ }
+ Log.e(TAG, "There's no available dvb devices");
+ return false;
+ }
+
+ /**
+ * Acquires the tuner device. The requested device will be locked to the current instance if
+ * it's not acquired by others.
+ *
+ * @param deviceInfo a tuner device to open
+ * @return {@code true} if the operation was successful, {@code false} otherwise
+ */
+ protected boolean open(DvbDeviceInfoWrapper deviceInfo) {
+ if (deviceInfo == null) {
+ Log.e(TAG, "Device info should not be null");
+ return false;
+ }
+ if (mDvbDeviceInfo != null) {
+ Log.e(TAG, "Already acquired");
+ return false;
+ }
+ List<DvbDeviceInfoWrapper> deviceInfoList = mDvbDeviceAccessor.getDvbDeviceList();
+ if (deviceInfoList == null || deviceInfoList.isEmpty()) {
+ Log.e(TAG, "There's no dvb device attached");
+ return false;
+ }
+ for (DvbDeviceInfoWrapper deviceInfoWrapper : deviceInfoList) {
+ if (deviceInfoWrapper.compareTo(deviceInfo) == 0) {
+ synchronized (sLock) {
+ if (sUsedDvbDevices.contains(deviceInfo)) {
+ Log.e(TAG, deviceInfo + " is already taken");
+ return false;
+ }
+ sUsedDvbDevices.add(deviceInfo);
+ }
+ if (DEBUG) Log.d(TAG, "Available device info: " + deviceInfo);
+ mDvbDeviceInfo = deviceInfo;
+ return true;
+ }
+ }
+ Log.e(TAG, "There's no such dvb device attached");
+ return false;
+ }
+
+ @Override
+ public void close() {
+ if (mDvbDeviceInfo != null) {
+ if (isStreaming()) {
+ stopTune();
+ }
+ nativeFinalize(mDvbDeviceInfo.getId());
+ synchronized (sLock) {
+ sUsedDvbDevices.remove(mDvbDeviceInfo);
+ }
+ mDvbDeviceInfo = null;
+ }
+ }
+
+ @Override
+ protected boolean isDeviceOpen() {
+ return (mDvbDeviceInfo != null);
+ }
+
+ @Override
+ protected long getDeviceId() {
+ if (mDvbDeviceInfo != null) {
+ return mDvbDeviceInfo.getId();
+ }
+ return -1;
+ }
+
+ @Override
+ protected int openDvbFrontEndFd() {
+ if (mDvbDeviceInfo != null) {
+ ParcelFileDescriptor descriptor =
+ mDvbDeviceAccessor.openDvbDevice(
+ mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_FRONTEND);
+ if (descriptor != null) {
+ return descriptor.detachFd();
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ protected int openDvbDemuxFd() {
+ if (mDvbDeviceInfo != null) {
+ ParcelFileDescriptor descriptor =
+ mDvbDeviceAccessor.openDvbDevice(
+ mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_DEMUX);
+ if (descriptor != null) {
+ return descriptor.detachFd();
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ protected int openDvbDvrFd() {
+ if (mDvbDeviceInfo != null) {
+ ParcelFileDescriptor descriptor =
+ mDvbDeviceAccessor.openDvbDevice(
+ mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_DVR);
+ if (descriptor != null) {
+ return descriptor.detachFd();
+ }
+ }
+ return -1;
+ }
+
+ /** Gets the number of USB tuner devices currently present. */
+ public static int getNumberOfDevices(Context context) {
+ try {
+ return (new DvbDeviceAccessor(context)).getNumOfDvbDevices();
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/TunerFeatures.java b/tuner/src/com/android/tv/tuner/TunerFeatures.java
new file mode 100644
index 00000000..e682e636
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/TunerFeatures.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2017 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;
+
+import static com.android.tv.common.feature.FeatureUtils.OFF;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Log;
+import com.android.tv.common.BaseApplication;
+import com.android.tv.common.config.api.RemoteConfig;
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.common.feature.Feature;
+import com.android.tv.common.feature.Model;
+import com.android.tv.common.feature.PropertyFeature;
+import com.android.tv.common.util.CommonUtils;
+import com.android.tv.common.util.LocationUtils;
+import java.util.Locale;
+
+/**
+ * List of {@link Feature} for Tuner.
+ *
+ * <p>Remove the {@code Feature} once it is launched.
+ */
+public class TunerFeatures extends CommonFeatures {
+ private static final String TAG = "TunerFeatures";
+ private static final boolean DEBUG = false;
+
+ /** Use network tuner if it is available and there is no other tuner types. */
+ public static final Feature NETWORK_TUNER =
+ new Feature() {
+ @Override
+ public boolean isEnabled(Context context) {
+ if (!TUNER.isEnabled(context)) {
+ return false;
+ }
+ if (CommonUtils.isDeveloper()) {
+ // Network tuner will be enabled for developers.
+ return true;
+ }
+ return Locale.US
+ .getCountry()
+ .equalsIgnoreCase(LocationUtils.getCurrentCountry(context));
+ }
+ };
+
+ /**
+ * USE_SW_CODEC_FOR_SD
+ *
+ * <p>Prefer software based codec for SD channels.
+ */
+ public static final Feature USE_SW_CODEC_FOR_SD =
+ PropertyFeature.create(
+ "use_sw_codec_for_sd",
+ false
+ );
+
+ /** Use AC3 software decode. */
+ public static final Feature AC3_SOFTWARE_DECODE =
+ new Feature() {
+ private final String[] SUPPORTED_REGIONS = {};
+
+ private Boolean mEnabled;
+
+ @Override
+ public boolean isEnabled(Context context) {
+ if (mEnabled == null) {
+ if (mEnabled == null) {
+ // We will not cache the result of fallback solution.
+ String country = LocationUtils.getCurrentCountry(context);
+ for (int i = 0; i < SUPPORTED_REGIONS.length; ++i) {
+ if (SUPPORTED_REGIONS[i].equalsIgnoreCase(country)) {
+ return true;
+ }
+ }
+ if (DEBUG) Log.d(TAG, "AC3 flag false after country check");
+ return false;
+ }
+ }
+ if (DEBUG) Log.d(TAG, "AC3 flag " + mEnabled);
+ return mEnabled;
+ }
+ };
+
+ /** Enable Dvb parsers and listeners. */
+ public static final Feature ENABLE_FILE_DVB = OFF;
+
+ private TunerFeatures() {}
+}
diff --git a/tuner/src/com/android/tv/tuner/TunerHal.java b/tuner/src/com/android/tv/tuner/TunerHal.java
new file mode 100644
index 00000000..5801406b
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/TunerHal.java
@@ -0,0 +1,369 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.support.annotation.IntDef;
+import android.support.annotation.StringDef;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+import android.util.Pair;
+import com.android.tv.common.BuildConfig;
+import com.android.tv.common.customization.CustomizationManager;
+
+
+import com.android.tv.common.annotation.UsedByNative;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
+
+/** A base class to handle a hardware tuner device. */
+public abstract class TunerHal implements AutoCloseable {
+ protected static final String TAG = "TunerHal";
+ protected static final boolean DEBUG = false;
+
+ @IntDef({FILTER_TYPE_OTHER, FILTER_TYPE_AUDIO, FILTER_TYPE_VIDEO, FILTER_TYPE_PCR})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface FilterType {}
+
+ public static final int FILTER_TYPE_OTHER = 0;
+ public static final int FILTER_TYPE_AUDIO = 1;
+ public static final int FILTER_TYPE_VIDEO = 2;
+ public static final int FILTER_TYPE_PCR = 3;
+
+ @StringDef({MODULATION_8VSB, MODULATION_QAM256})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface ModulationType {}
+
+ public static final String MODULATION_8VSB = "8VSB";
+ public static final String MODULATION_QAM256 = "QAM256";
+
+ @IntDef({
+ DELIVERY_SYSTEM_UNDEFINED,
+ DELIVERY_SYSTEM_ATSC,
+ DELIVERY_SYSTEM_DVBC,
+ DELIVERY_SYSTEM_DVBS,
+ DELIVERY_SYSTEM_DVBS2,
+ DELIVERY_SYSTEM_DVBT,
+ DELIVERY_SYSTEM_DVBT2
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface DeliverySystemType {}
+
+ public static final int DELIVERY_SYSTEM_UNDEFINED = 0;
+ public static final int DELIVERY_SYSTEM_ATSC = 1;
+ public static final int DELIVERY_SYSTEM_DVBC = 2;
+ public static final int DELIVERY_SYSTEM_DVBS = 3;
+ public static final int DELIVERY_SYSTEM_DVBS2 = 4;
+ public static final int DELIVERY_SYSTEM_DVBT = 5;
+ public static final int DELIVERY_SYSTEM_DVBT2 = 6;
+
+ @IntDef({TUNER_TYPE_BUILT_IN, TUNER_TYPE_USB, TUNER_TYPE_NETWORK})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface TunerType {}
+
+ public static final int TUNER_TYPE_BUILT_IN = 1;
+ public static final int TUNER_TYPE_USB = 2;
+ public static final int TUNER_TYPE_NETWORK = 3;
+
+ protected static final int PID_PAT = 0;
+ protected static final int PID_ATSC_SI_BASE = 0x1ffb;
+ protected static final int PID_DVB_SDT = 0x0011;
+ protected static final int PID_DVB_EIT = 0x0012;
+ protected static final int DEFAULT_VSB_TUNE_TIMEOUT_MS = 2000;
+ protected static final int DEFAULT_QAM_TUNE_TIMEOUT_MS = 4000; // Some device takes time for
+ // QAM256 tuning.
+ @IntDef({
+ BUILT_IN_TUNER_TYPE_LINUX_DVB
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ private @interface BuiltInTunerType {}
+
+ private static final int BUILT_IN_TUNER_TYPE_LINUX_DVB = 1;
+
+ private static Integer sBuiltInTunerType;
+
+ protected @DeliverySystemType int mDeliverySystemType;
+ private boolean mIsStreaming;
+ private int mFrequency;
+ private String mModulation;
+
+ static {
+ if (!BuildConfig.NO_JNI_TEST) {
+ System.loadLibrary("tunertvinput_jni");
+ }
+ }
+
+ /**
+ * Creates a TunerHal instance.
+ *
+ * @param context context for creating the TunerHal instance
+ * @return the TunerHal instance
+ */
+ @WorkerThread
+ public static synchronized TunerHal createInstance(Context context) {
+ TunerHal tunerHal = null;
+ if (DvbTunerHal.getNumberOfDevices(context) > 0) {
+ if (DEBUG) Log.d(TAG, "Use DvbTunerHal");
+ tunerHal = new DvbTunerHal(context);
+ }
+ return tunerHal != null && tunerHal.openFirstAvailable() ? tunerHal : null;
+ }
+
+ /** Gets the number of tuner devices currently present. */
+ @WorkerThread
+ public static Pair<Integer, Integer> getTunerTypeAndCount(Context context) {
+ if (useBuiltInTuner(context)) {
+ if (getBuiltInTunerType(context) == BUILT_IN_TUNER_TYPE_LINUX_DVB) {
+ return new Pair<>(TUNER_TYPE_BUILT_IN, DvbTunerHal.getNumberOfDevices(context));
+ }
+ } else {
+ int usbTunerCount = DvbTunerHal.getNumberOfDevices(context);
+ if (usbTunerCount > 0) {
+ return new Pair<>(TUNER_TYPE_USB, usbTunerCount);
+ }
+ }
+ return new Pair<>(null, 0);
+ }
+
+ /** Check a delivery system is for DVB or not. */
+ public static boolean isDvbDeliverySystem(@DeliverySystemType int deliverySystemType) {
+ return deliverySystemType == DELIVERY_SYSTEM_DVBC
+ || deliverySystemType == DELIVERY_SYSTEM_DVBS
+ || deliverySystemType == DELIVERY_SYSTEM_DVBS2
+ || deliverySystemType == DELIVERY_SYSTEM_DVBT
+ || deliverySystemType == DELIVERY_SYSTEM_DVBT2;
+ }
+
+ /**
+ * Returns if tuner input service would use built-in tuners instead of USB tuners or network
+ * tuners.
+ */
+ public static boolean useBuiltInTuner(Context context) {
+ return getBuiltInTunerType(context) != 0;
+ }
+
+ private static @BuiltInTunerType int getBuiltInTunerType(Context context) {
+ if (sBuiltInTunerType == null) {
+ sBuiltInTunerType = 0;
+ if (CustomizationManager.hasLinuxDvbBuiltInTuner(context)
+ && DvbTunerHal.getNumberOfDevices(context) > 0) {
+ sBuiltInTunerType = BUILT_IN_TUNER_TYPE_LINUX_DVB;
+ }
+ }
+ return sBuiltInTunerType;
+ }
+
+ protected TunerHal(Context context) {
+ mIsStreaming = false;
+ mFrequency = -1;
+ mModulation = null;
+ }
+
+ protected boolean isStreaming() {
+ return mIsStreaming;
+ }
+
+ protected void getDeliverySystemTypeFromDevice() {
+ if (mDeliverySystemType == DELIVERY_SYSTEM_UNDEFINED) {
+ mDeliverySystemType = nativeGetDeliverySystemType(getDeviceId());
+ }
+ }
+
+ /**
+ * Returns {@code true} if this tuner HAL can be reused to save tuning time between channels of
+ * the same frequency.
+ */
+ public boolean isReusable() {
+ return true;
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ super.finalize();
+ close();
+ }
+
+ protected native void nativeFinalize(long deviceId);
+
+ /**
+ * Acquires the first available tuner device. If there is a tuner device that is available, the
+ * tuner device will be locked to the current instance.
+ *
+ * @return {@code true} if the operation was successful, {@code false} otherwise
+ */
+ protected abstract boolean openFirstAvailable();
+
+ protected abstract boolean isDeviceOpen();
+
+ protected abstract long getDeviceId();
+
+ /**
+ * Sets the tuner channel. This should be called after acquiring a tuner device.
+ *
+ * @param frequency a frequency of the channel to tune to
+ * @param modulation a modulation method of the channel to tune to
+ * @param channelNumber channel number when channel number is already known. Some tuner HAL may
+ * use channelNumber instead of frequency for tune.
+ * @return {@code true} if the operation was successful, {@code false} otherwise
+ */
+ public synchronized boolean tune(
+ int frequency, @ModulationType String modulation, String channelNumber) {
+ if (!isDeviceOpen()) {
+ Log.e(TAG, "There's no available device");
+ return false;
+ }
+ if (mIsStreaming) {
+ nativeCloseAllPidFilters(getDeviceId());
+ mIsStreaming = false;
+ }
+
+ // When tuning to a new channel in the same frequency, there's no need to stop current tuner
+ // device completely and the only thing necessary for tuning is reopening pid filters.
+ if (mFrequency == frequency && Objects.equals(mModulation, modulation)) {
+ addPidFilter(PID_PAT, FILTER_TYPE_OTHER);
+ addPidFilter(PID_ATSC_SI_BASE, FILTER_TYPE_OTHER);
+ if (isDvbDeliverySystem(mDeliverySystemType)) {
+ addPidFilter(PID_DVB_SDT, FILTER_TYPE_OTHER);
+ addPidFilter(PID_DVB_EIT, FILTER_TYPE_OTHER);
+ }
+ mIsStreaming = true;
+ return true;
+ }
+ int timeout_ms =
+ modulation.equals(MODULATION_8VSB)
+ ? DEFAULT_VSB_TUNE_TIMEOUT_MS
+ : DEFAULT_QAM_TUNE_TIMEOUT_MS;
+ if (nativeTune(getDeviceId(), frequency, modulation, timeout_ms)) {
+ addPidFilter(PID_PAT, FILTER_TYPE_OTHER);
+ addPidFilter(PID_ATSC_SI_BASE, FILTER_TYPE_OTHER);
+ if (isDvbDeliverySystem(mDeliverySystemType)) {
+ addPidFilter(PID_DVB_SDT, FILTER_TYPE_OTHER);
+ addPidFilter(PID_DVB_EIT, FILTER_TYPE_OTHER);
+ }
+ mFrequency = frequency;
+ mModulation = modulation;
+ mIsStreaming = true;
+ return true;
+ }
+ return false;
+ }
+
+ protected native boolean nativeTune(
+ long deviceId, int frequency, @ModulationType String modulation, int timeout_ms);
+
+ /**
+ * Sets a pid filter. This should be set after setting a channel.
+ *
+ * @param pid a pid number to be added to filter list
+ * @param filterType a type of pid. Must be one of (FILTER_TYPE_XXX)
+ * @return {@code true} if the operation was successful, {@code false} otherwise
+ */
+ public synchronized boolean addPidFilter(int pid, @FilterType int filterType) {
+ if (!isDeviceOpen()) {
+ Log.e(TAG, "There's no available device");
+ return false;
+ }
+ if (pid >= 0 && pid <= 0x1fff) {
+ nativeAddPidFilter(getDeviceId(), pid, filterType);
+ return true;
+ }
+ return false;
+ }
+
+ protected native void nativeAddPidFilter(long deviceId, int pid, @FilterType int filterType);
+
+ protected native void nativeCloseAllPidFilters(long deviceId);
+
+ protected native void nativeSetHasPendingTune(long deviceId, boolean hasPendingTune);
+
+ protected native int nativeGetDeliverySystemType(long deviceId);
+
+ /**
+ * Stops current tuning. The tuner device and pid filters will be reset by this call and make
+ * the tuner ready to accept another tune request.
+ */
+ public synchronized void stopTune() {
+ if (isDeviceOpen()) {
+ if (mIsStreaming) {
+ nativeCloseAllPidFilters(getDeviceId());
+ }
+ nativeStopTune(getDeviceId());
+ }
+ mIsStreaming = false;
+ mFrequency = -1;
+ mModulation = null;
+ }
+
+ public void setHasPendingTune(boolean hasPendingTune) {
+ nativeSetHasPendingTune(getDeviceId(), hasPendingTune);
+ }
+
+ public int getDeliverySystemType() {
+ return mDeliverySystemType;
+ }
+
+ protected native void nativeStopTune(long deviceId);
+
+ /**
+ * This method must be called after {@link TunerHal#tune} and before {@link TunerHal#stopTune}.
+ * Writes at most maxSize TS frames in a buffer provided by the user. The frames employ MPEG
+ * encoding.
+ *
+ * @param javaBuffer a buffer to write the video data in
+ * @param javaBufferSize the max amount of bytes to write in this buffer. Usually this number
+ * should be equal to the length of the buffer.
+ * @return the amount of bytes written in the buffer. Note that this value could be 0 if no new
+ * frames have been obtained since the last call.
+ */
+ public synchronized int readTsStream(byte[] javaBuffer, int javaBufferSize) {
+ if (isDeviceOpen()) {
+ return nativeWriteInBuffer(getDeviceId(), javaBuffer, javaBufferSize);
+ } else {
+ return 0;
+ }
+ }
+
+ protected native int nativeWriteInBuffer(long deviceId, byte[] javaBuffer, int javaBufferSize);
+
+ /**
+ * Opens Linux DVB frontend device. This method is called from native JNI and used only for
+ * DvbTunerHal.
+ */
+ @UsedByNative("DvbManager.cpp")
+ protected int openDvbFrontEndFd() {
+ return -1;
+ }
+
+ /**
+ * Opens Linux DVB demux device. This method is called from native JNI and used only for
+ * DvbTunerHal.
+ */
+ @UsedByNative("DvbManager.cpp")
+ protected int openDvbDemuxFd() {
+ return -1;
+ }
+
+ /**
+ * Opens Linux DVB dvr device. This method is called from native JNI and used only for
+ * DvbTunerHal.
+ */
+ @UsedByNative("DvbManager.cpp")
+ protected int openDvbDvrFd() {
+ return -1;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/TunerPreferences.java b/tuner/src/com/android/tv/tuner/TunerPreferences.java
new file mode 100644
index 00000000..7b45b997
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/TunerPreferences.java
@@ -0,0 +1,100 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import com.android.tv.common.CommonConstants;
+import com.android.tv.common.CommonPreferences;
+import com.android.tv.common.SoftPreconditions;
+
+/** A helper class for the tuner preferences. */
+public class TunerPreferences extends CommonPreferences {
+ private static final String TAG = "TunerPreferences";
+
+ private static final String PREFS_KEY_CHANNEL_DATA_VERSION = "channel_data_version";
+ private static final String PREFS_KEY_SCANNED_CHANNEL_COUNT = "scanned_channel_count";
+ private static final String PREFS_KEY_SCAN_DONE = "scan_done";
+ private static final String PREFS_KEY_TRICKPLAY_EXPIRED_MS = "trickplay_expired_ms";
+
+ private static final String SHARED_PREFS_NAME =
+ CommonConstants.BASE_PACKAGE + ".tuner.preferences";
+
+ public static final int CHANNEL_DATA_VERSION_NOT_SET = -1;
+
+ protected static SharedPreferences getSharedPreferences(Context context) {
+ return context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE);
+ }
+
+ public static synchronized int getChannelDataVersion(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ return getSharedPreferences(context)
+ .getInt(
+ TunerPreferences.PREFS_KEY_CHANNEL_DATA_VERSION,
+ CHANNEL_DATA_VERSION_NOT_SET);
+ }
+
+ public static synchronized void setChannelDataVersion(Context context, int version) {
+ SoftPreconditions.checkState(sInitialized);
+ getSharedPreferences(context)
+ .edit()
+ .putInt(TunerPreferences.PREFS_KEY_CHANNEL_DATA_VERSION, version)
+ .apply();
+ }
+
+ public static synchronized int getScannedChannelCount(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ return getSharedPreferences(context)
+ .getInt(TunerPreferences.PREFS_KEY_SCANNED_CHANNEL_COUNT, 0);
+ }
+
+ public static synchronized void setScannedChannelCount(Context context, int channelCount) {
+ SoftPreconditions.checkState(sInitialized);
+ getSharedPreferences(context)
+ .edit()
+ .putInt(TunerPreferences.PREFS_KEY_SCANNED_CHANNEL_COUNT, channelCount)
+ .apply();
+ }
+
+ public static synchronized boolean isScanDone(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ return getSharedPreferences(context)
+ .getBoolean(TunerPreferences.PREFS_KEY_SCAN_DONE, false);
+ }
+
+ public static synchronized void setScanDone(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ getSharedPreferences(context)
+ .edit()
+ .putBoolean(TunerPreferences.PREFS_KEY_SCAN_DONE, true)
+ .apply();
+ }
+
+ public static synchronized long getTrickplayExpiredMs(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ return getSharedPreferences(context)
+ .getLong(TunerPreferences.PREFS_KEY_TRICKPLAY_EXPIRED_MS, 0);
+ }
+
+ public static synchronized void setTrickplayExpiredMs(Context context, long timeMs) {
+ SoftPreconditions.checkState(sInitialized);
+ getSharedPreferences(context)
+ .edit()
+ .putLong(TunerPreferences.PREFS_KEY_TRICKPLAY_EXPIRED_MS, timeMs)
+ .apply();
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/cc/CaptionLayout.java b/tuner/src/com/android/tv/tuner/cc/CaptionLayout.java
new file mode 100644
index 00000000..eb9ad463
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/cc/CaptionLayout.java
@@ -0,0 +1,77 @@
+/*
+ * 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.cc;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import com.android.tv.tuner.layout.ScaledLayout;
+
+/**
+ * Layout containing the safe title area that helps the closed captions look more prominent. This is
+ * required by CEA-708B.
+ */
+public class CaptionLayout extends ScaledLayout {
+ // The safe title area has 10% margins of the screen.
+ private static final float SAFE_TITLE_AREA_SCALE_START_X = 0.1f;
+ private static final float SAFE_TITLE_AREA_SCALE_END_X = 0.9f;
+ private static final float SAFE_TITLE_AREA_SCALE_START_Y = 0.1f;
+ private static final float SAFE_TITLE_AREA_SCALE_END_Y = 0.9f;
+
+ private final ScaledLayout mSafeTitleAreaLayout;
+ private AtscCaptionTrack mCaptionTrack;
+
+ public CaptionLayout(Context context) {
+ this(context, null);
+ }
+
+ public CaptionLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public CaptionLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ mSafeTitleAreaLayout = new ScaledLayout(context);
+ addView(
+ mSafeTitleAreaLayout,
+ new ScaledLayoutParams(
+ SAFE_TITLE_AREA_SCALE_START_X, SAFE_TITLE_AREA_SCALE_END_X,
+ SAFE_TITLE_AREA_SCALE_START_Y, SAFE_TITLE_AREA_SCALE_END_Y));
+ }
+
+ public void addOrUpdateViewToSafeTitleArea(
+ CaptionWindowLayout captionWindowLayout, ScaledLayoutParams scaledLayoutParams) {
+ int index = mSafeTitleAreaLayout.indexOfChild(captionWindowLayout);
+ if (index < 0) {
+ mSafeTitleAreaLayout.addView(captionWindowLayout, scaledLayoutParams);
+ return;
+ }
+ mSafeTitleAreaLayout.updateViewLayout(captionWindowLayout, scaledLayoutParams);
+ }
+
+ public void removeViewFromSafeTitleArea(CaptionWindowLayout captionWindowLayout) {
+ mSafeTitleAreaLayout.removeView(captionWindowLayout);
+ }
+
+ public void setCaptionTrack(AtscCaptionTrack captionTrack) {
+ mCaptionTrack = captionTrack;
+ }
+
+ public AtscCaptionTrack getCaptionTrack() {
+ return mCaptionTrack;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java b/tuner/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java
new file mode 100644
index 00000000..84033240
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java
@@ -0,0 +1,339 @@
+/*
+ * 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.cc;
+
+import android.os.Handler;
+import android.os.Message;
+import android.util.Log;
+import android.view.View;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenColor;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenLocation;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindow;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import java.util.ArrayList;
+
+/** Decodes and renders CEA-708. */
+public class CaptionTrackRenderer implements Handler.Callback {
+ // TODO: Remaining works
+ // CaptionTrackRenderer does not support the full spec of CEA-708. The remaining works are
+ // described in the follows.
+ // C0 Table: Backspace, FF, and HCR are not supported. The rule for P16 is not standardized but
+ // it is handled as EUC-KR charset for korea broadcasting.
+ // C1 Table: All styles of windows and pens except underline, italic, pen size, and pen offset
+ // specified in CEA-708 are ignored and this follows system wide cc preferences for
+ // look and feel. SetPenLocation is not implemented.
+ // G2 Table: TSP, NBTSP and BLK are not supported.
+ // Text/commands: Word wrapping, fonts, row and column locking are not supported.
+
+ private static final String TAG = "CaptionTrackRenderer";
+ private static final boolean DEBUG = false;
+
+ private static final long DELAY_IN_MILLIS = 100 /* milliseconds */;
+
+ // According to CEA-708B, there can exist up to 8 caption windows.
+ private static final int CAPTION_WINDOWS_MAX = 8;
+ private static final int CAPTION_ALL_WINDOWS_BITMAP = 255;
+
+ private static final int MSG_DELAY_CANCEL = 1;
+ private static final int MSG_CAPTION_CLEAR = 2;
+
+ private static final long CAPTION_CLEAR_INTERVAL_MS = 60000;
+
+ private final CaptionLayout mCaptionLayout;
+ private boolean mIsDelayed = false;
+ private CaptionWindowLayout mCurrentWindowLayout;
+ private final CaptionWindowLayout[] mCaptionWindowLayouts =
+ new CaptionWindowLayout[CAPTION_WINDOWS_MAX];
+ private final ArrayList<CaptionEvent> mPendingCaptionEvents = new ArrayList<>();
+ private final Handler mHandler;
+
+ public CaptionTrackRenderer(CaptionLayout captionLayout) {
+ mCaptionLayout = captionLayout;
+ mHandler = new Handler(this);
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_DELAY_CANCEL:
+ delayCancel();
+ return true;
+ case MSG_CAPTION_CLEAR:
+ clearWindows(CAPTION_ALL_WINDOWS_BITMAP);
+ return true;
+ }
+ return false;
+ }
+
+ public void start(AtscCaptionTrack captionTrack) {
+ if (captionTrack == null) {
+ stop();
+ return;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "Start captionTrack " + captionTrack.language);
+ }
+ reset();
+ mCaptionLayout.setCaptionTrack(captionTrack);
+ mCaptionLayout.setVisibility(View.VISIBLE);
+ }
+
+ public void stop() {
+ if (DEBUG) {
+ Log.d(TAG, "Stop captionTrack");
+ }
+ mCaptionLayout.setVisibility(View.INVISIBLE);
+ mHandler.removeMessages(MSG_CAPTION_CLEAR);
+ }
+
+ public void processCaptionEvent(CaptionEvent event) {
+ if (mIsDelayed) {
+ mPendingCaptionEvents.add(event);
+ return;
+ }
+ switch (event.type) {
+ case Cea708Parser.CAPTION_EMIT_TYPE_BUFFER:
+ sendBufferToCurrentWindow((String) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_CONTROL:
+ sendControlToCurrentWindow((char) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_CWX:
+ setCurrentWindowLayout((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_CLW:
+ clearWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DSW:
+ displayWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_HDW:
+ hideWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_TGW:
+ toggleWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLW:
+ deleteWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLY:
+ delay((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLC:
+ delayCancel();
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_RST:
+ reset();
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPA:
+ setPenAttr((CaptionPenAttr) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPC:
+ setPenColor((CaptionPenColor) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPL:
+ setPenLocation((CaptionPenLocation) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SWA:
+ setWindowAttr((CaptionWindowAttr) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DFX:
+ defineWindow((CaptionWindow) event.obj);
+ break;
+ }
+ }
+
+ // The window related caption commands
+ private void setCurrentWindowLayout(int windowId) {
+ if (windowId < 0 || windowId >= mCaptionWindowLayouts.length) {
+ return;
+ }
+ CaptionWindowLayout windowLayout = mCaptionWindowLayouts[windowId];
+ if (windowLayout == null) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "setCurrentWindowLayout to " + windowId);
+ }
+ mCurrentWindowLayout = windowLayout;
+ }
+
+ // Each bit of windowBitmap indicates a window.
+ // If a bit is set, the window id is the same as the number of the trailing zeros of the bit.
+ private ArrayList<CaptionWindowLayout> getWindowsFromBitmap(int windowBitmap) {
+ ArrayList<CaptionWindowLayout> windows = new ArrayList<>();
+ for (int i = 0; i < CAPTION_WINDOWS_MAX; ++i) {
+ if ((windowBitmap & (1 << i)) != 0) {
+ CaptionWindowLayout windowLayout = mCaptionWindowLayouts[i];
+ if (windowLayout != null) {
+ windows.add(windowLayout);
+ }
+ }
+ }
+ return windows;
+ }
+
+ private void clearWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ windowLayout.clear();
+ }
+ }
+
+ private void displayWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ windowLayout.show();
+ }
+ }
+
+ private void hideWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ windowLayout.hide();
+ }
+ }
+
+ private void toggleWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ if (windowLayout.isShown()) {
+ windowLayout.hide();
+ } else {
+ windowLayout.show();
+ }
+ }
+ }
+
+ private void deleteWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ windowLayout.removeFromCaptionView();
+ mCaptionWindowLayouts[windowLayout.getCaptionWindowId()] = null;
+ }
+ }
+
+ public void clear() {
+ mHandler.sendEmptyMessage(MSG_CAPTION_CLEAR);
+ }
+
+ public void reset() {
+ mCurrentWindowLayout = null;
+ mIsDelayed = false;
+ mPendingCaptionEvents.clear();
+ for (int i = 0; i < CAPTION_WINDOWS_MAX; ++i) {
+ if (mCaptionWindowLayouts[i] != null) {
+ mCaptionWindowLayouts[i].removeFromCaptionView();
+ }
+ mCaptionWindowLayouts[i] = null;
+ }
+ mCaptionLayout.setVisibility(View.INVISIBLE);
+ mHandler.removeMessages(MSG_CAPTION_CLEAR);
+ }
+
+ private void setWindowAttr(CaptionWindowAttr windowAttr) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.setWindowAttr(windowAttr);
+ }
+ }
+
+ private void defineWindow(CaptionWindow window) {
+ if (window == null) {
+ return;
+ }
+ int windowId = window.id;
+ if (windowId < 0 || windowId >= mCaptionWindowLayouts.length) {
+ return;
+ }
+ CaptionWindowLayout windowLayout = mCaptionWindowLayouts[windowId];
+ if (windowLayout == null) {
+ windowLayout = new CaptionWindowLayout(mCaptionLayout.getContext());
+ }
+ windowLayout.initWindow(mCaptionLayout, window);
+ mCurrentWindowLayout = mCaptionWindowLayouts[windowId] = windowLayout;
+ }
+
+ // The job related caption commands
+ private void delay(int tenthsOfSeconds) {
+ if (tenthsOfSeconds < 0 || tenthsOfSeconds > 255) {
+ return;
+ }
+ mIsDelayed = true;
+ mHandler.sendMessageDelayed(
+ mHandler.obtainMessage(MSG_DELAY_CANCEL), tenthsOfSeconds * DELAY_IN_MILLIS);
+ }
+
+ private void delayCancel() {
+ mIsDelayed = false;
+ processPendingBuffer();
+ }
+
+ private void processPendingBuffer() {
+ for (CaptionEvent event : mPendingCaptionEvents) {
+ processCaptionEvent(event);
+ }
+ mPendingCaptionEvents.clear();
+ }
+
+ // The implicit write caption commands
+ private void sendControlToCurrentWindow(char control) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.sendControl(control);
+ }
+ }
+
+ private void sendBufferToCurrentWindow(String buffer) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.sendBuffer(buffer);
+ mHandler.removeMessages(MSG_CAPTION_CLEAR);
+ mHandler.sendMessageDelayed(
+ mHandler.obtainMessage(MSG_CAPTION_CLEAR), CAPTION_CLEAR_INTERVAL_MS);
+ }
+ }
+
+ // The pen related caption commands
+ private void setPenAttr(CaptionPenAttr attr) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.setPenAttr(attr);
+ }
+ }
+
+ private void setPenColor(CaptionPenColor color) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.setPenColor(color);
+ }
+ }
+
+ private void setPenLocation(CaptionPenLocation location) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.setPenLocation(location.row, location.column);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/cc/CaptionWindowLayout.java b/tuner/src/com/android/tv/tuner/cc/CaptionWindowLayout.java
new file mode 100644
index 00000000..13c6ff47
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/cc/CaptionWindowLayout.java
@@ -0,0 +1,680 @@
+/*
+ * 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.cc;
+
+import android.content.Context;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.text.Layout.Alignment;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.style.CharacterStyle;
+import android.text.style.RelativeSizeSpan;
+import android.text.style.StyleSpan;
+import android.text.style.SubscriptSpan;
+import android.text.style.SuperscriptSpan;
+import android.text.style.UnderlineSpan;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.CaptioningManager;
+import android.view.accessibility.CaptioningManager.CaptionStyle;
+import android.view.accessibility.CaptioningManager.CaptioningChangeListener;
+import android.widget.RelativeLayout;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenColor;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindow;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr;
+import com.android.tv.tuner.exoplayer.text.SubtitleView;
+import com.android.tv.tuner.layout.ScaledLayout;
+import com.google.android.exoplayer.text.CaptionStyleCompat;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Layout which renders a caption window of CEA-708B. It contains a {@link SubtitleView} that takes
+ * care of displaying the actual cc text.
+ */
+public class CaptionWindowLayout extends RelativeLayout implements View.OnLayoutChangeListener {
+ private static final String TAG = "CaptionWindowLayout";
+ private static final boolean DEBUG = false;
+
+ private static final float PROPORTION_PEN_SIZE_SMALL = .75f;
+ private static final float PROPORTION_PEN_SIZE_LARGE = 1.25f;
+
+ // The following values indicates the maximum cell number of a window.
+ private static final int ANCHOR_RELATIVE_POSITIONING_MAX = 99;
+ private static final int ANCHOR_VERTICAL_MAX = 74;
+ private static final int ANCHOR_HORIZONTAL_4_3_MAX = 159;
+ private static final int ANCHOR_HORIZONTAL_16_9_MAX = 209;
+
+ // The following values indicates a gravity of a window.
+ private static final int ANCHOR_MODE_DIVIDER = 3;
+ private static final int ANCHOR_HORIZONTAL_MODE_LEFT = 0;
+ private static final int ANCHOR_HORIZONTAL_MODE_CENTER = 1;
+ private static final int ANCHOR_HORIZONTAL_MODE_RIGHT = 2;
+ private static final int ANCHOR_VERTICAL_MODE_TOP = 0;
+ private static final int ANCHOR_VERTICAL_MODE_CENTER = 1;
+ private static final int ANCHOR_VERTICAL_MODE_BOTTOM = 2;
+
+ private static final int US_MAX_COLUMN_COUNT_16_9 = 42;
+ private static final int US_MAX_COLUMN_COUNT_4_3 = 32;
+ private static final int KR_MAX_COLUMN_COUNT_16_9 = 52;
+ private static final int KR_MAX_COLUMN_COUNT_4_3 = 40;
+ private static final int MAX_ROW_COUNT = 15;
+
+ private static final String KOR_ALPHABET =
+ new String("\uAC00".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
+ private static final float WIDE_SCREEN_ASPECT_RATIO_THRESHOLD = 1.6f;
+
+ private CaptionLayout mCaptionLayout;
+ private CaptionStyleCompat mCaptionStyleCompat;
+
+ // TODO: Replace SubtitleView to {@link com.google.android.exoplayer.text.SubtitleLayout}.
+ private final SubtitleView mSubtitleView;
+ private int mRowLimit = 0;
+ private final SpannableStringBuilder mBuilder = new SpannableStringBuilder();
+ private final List<CharacterStyle> mCharacterStyles = new ArrayList<>();
+ private int mCaptionWindowId;
+ private int mCurrentTextRow = -1;
+ private float mFontScale;
+ private float mTextSize;
+ private String mWidestChar;
+ private int mLastCaptionLayoutWidth;
+ private int mLastCaptionLayoutHeight;
+ private int mWindowJustify;
+ private int mPrintDirection;
+
+ private class SystemWideCaptioningChangeListener extends CaptioningChangeListener {
+ @Override
+ public void onUserStyleChanged(CaptionStyle userStyle) {
+ mCaptionStyleCompat = CaptionStyleCompat.createFromCaptionStyle(userStyle);
+ mSubtitleView.setStyle(mCaptionStyleCompat);
+ updateWidestChar();
+ }
+
+ @Override
+ public void onFontScaleChanged(float fontScale) {
+ mFontScale = fontScale;
+ updateTextSize();
+ }
+ }
+
+ public CaptionWindowLayout(Context context) {
+ this(context, null);
+ }
+
+ public CaptionWindowLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public CaptionWindowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ // Add a subtitle view to the layout.
+ mSubtitleView = new SubtitleView(context);
+ LayoutParams params =
+ new RelativeLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ addView(mSubtitleView, params);
+
+ // Set the system wide cc preferences to the subtitle view.
+ CaptioningManager captioningManager =
+ (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE);
+ mFontScale = captioningManager.getFontScale();
+ mCaptionStyleCompat =
+ CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle());
+ mSubtitleView.setStyle(mCaptionStyleCompat);
+ mSubtitleView.setText("");
+ captioningManager.addCaptioningChangeListener(new SystemWideCaptioningChangeListener());
+ updateWidestChar();
+ }
+
+ public int getCaptionWindowId() {
+ return mCaptionWindowId;
+ }
+
+ public void setCaptionWindowId(int captionWindowId) {
+ mCaptionWindowId = captionWindowId;
+ }
+
+ public void clear() {
+ clearText();
+ hide();
+ }
+
+ public void show() {
+ setVisibility(View.VISIBLE);
+ requestLayout();
+ }
+
+ public void hide() {
+ setVisibility(View.INVISIBLE);
+ requestLayout();
+ }
+
+ public void setPenAttr(CaptionPenAttr penAttr) {
+ mCharacterStyles.clear();
+ if (penAttr.italic) {
+ mCharacterStyles.add(new StyleSpan(Typeface.ITALIC));
+ }
+ if (penAttr.underline) {
+ mCharacterStyles.add(new UnderlineSpan());
+ }
+ switch (penAttr.penSize) {
+ case CaptionPenAttr.PEN_SIZE_SMALL:
+ mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_SMALL));
+ break;
+ case CaptionPenAttr.PEN_SIZE_LARGE:
+ mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_LARGE));
+ break;
+ }
+ switch (penAttr.penOffset) {
+ case CaptionPenAttr.OFFSET_SUBSCRIPT:
+ mCharacterStyles.add(new SubscriptSpan());
+ break;
+ case CaptionPenAttr.OFFSET_SUPERSCRIPT:
+ mCharacterStyles.add(new SuperscriptSpan());
+ break;
+ }
+ }
+
+ public void setPenColor(CaptionPenColor penColor) {
+ // TODO: apply pen colors or skip this and use the style of system wide cc style as is.
+ }
+
+ public void setPenLocation(int row, int column) {
+ // TODO: change the location of pen when window's justify isn't left.
+ // According to the CEA708B spec 8.7, setPenLocation means set the pen cursor within
+ // window's text buffer. When row > mCurrentTextRow, we add "\n" to make the cursor locate
+ // at row. Adding white space to make cursor locate at column.
+ if (mWindowJustify == CaptionWindowAttr.JUSTIFY_LEFT) {
+ if (mCurrentTextRow >= 0) {
+ for (int r = mCurrentTextRow; r < row; ++r) {
+ appendText("\n");
+ }
+ if (mCurrentTextRow <= row) {
+ for (int i = 0; i < column; ++i) {
+ appendText(" ");
+ }
+ }
+ }
+ }
+ mCurrentTextRow = row;
+ }
+
+ public void setWindowAttr(CaptionWindowAttr windowAttr) {
+ // TODO: apply window attrs or skip this and use the style of system wide cc style as is.
+ mWindowJustify = windowAttr.justify;
+ mPrintDirection = windowAttr.printDirection;
+ }
+
+ public void sendBuffer(String buffer) {
+ appendText(buffer);
+ }
+
+ public void sendControl(char control) {
+ // TODO: there are a bunch of ASCII-style control codes.
+ }
+
+ /**
+ * This method places the window on a given CaptionLayout along with the anchor of the window.
+ *
+ * <p>According to CEA-708B, the anchor id indicates the gravity of the window as the follows.
+ * For example, A value 7 of a anchor id says that a window is align with its parent bottom and
+ * is located at the center horizontally of its parent.
+ *
+ * <h4>Anchor id and the gravity of a window</h4>
+ *
+ * <table>
+ * <tr>
+ * <th>GRAVITY</th>
+ * <th>LEFT</th>
+ * <th>CENTER_HORIZONTAL</th>
+ * <th>RIGHT</th>
+ * </tr>
+ * <tr>
+ * <th>TOP</th>
+ * <td>0</td>
+ * <td>1</td>
+ * <td>2</td>
+ * </tr>
+ * <tr>
+ * <th>CENTER_VERTICAL</th>
+ * <td>3</td>
+ * <td>4</td>
+ * <td>5</td>
+ * </tr>
+ * <tr>
+ * <th>BOTTOM</th>
+ * <td>6</td>
+ * <td>7</td>
+ * <td>8</td>
+ * </tr>
+ * </table>
+ *
+ * <p>In order to handle the gravity of a window, there are two steps. First, set the size of
+ * the window. Since the window will be positioned at {@link ScaledLayout}, the size factors are
+ * determined in a ratio. Second, set the gravity of the window. {@link CaptionWindowLayout} is
+ * inherited from {@link RelativeLayout}. Hence, we could set the gravity of its child view,
+ * {@link SubtitleView}.
+ *
+ * <p>The gravity of the window is also related to its size. When it should be pushed to a one
+ * of the end of the window, like LEFT, RIGHT, TOP or BOTTOM, the anchor point should be a
+ * boundary of the window. When it should be pushed in the horizontal/vertical center of its
+ * container, the horizontal/vertical center point of the window should be the same as the
+ * anchor point.
+ *
+ * @param captionLayout a given {@link CaptionLayout}, which contains a safe title area
+ * @param captionWindow a given {@link CaptionWindow}, which stores the construction info of the
+ * window
+ */
+ public void initWindow(CaptionLayout captionLayout, CaptionWindow captionWindow) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "initWindow with "
+ + (captionLayout != null ? captionLayout.getCaptionTrack() : null));
+ }
+ if (mCaptionLayout != captionLayout) {
+ if (mCaptionLayout != null) {
+ mCaptionLayout.removeOnLayoutChangeListener(this);
+ }
+ mCaptionLayout = captionLayout;
+ mCaptionLayout.addOnLayoutChangeListener(this);
+ updateWidestChar();
+ }
+
+ // Both anchor vertical and horizontal indicates the position cell number of the window.
+ float scaleRow =
+ (float) captionWindow.anchorVertical
+ / (captionWindow.relativePositioning
+ ? ANCHOR_RELATIVE_POSITIONING_MAX
+ : ANCHOR_VERTICAL_MAX);
+ float scaleCol =
+ (float) captionWindow.anchorHorizontal
+ / (captionWindow.relativePositioning
+ ? ANCHOR_RELATIVE_POSITIONING_MAX
+ : (isWideAspectRatio()
+ ? ANCHOR_HORIZONTAL_16_9_MAX
+ : ANCHOR_HORIZONTAL_4_3_MAX));
+
+ // The range of scaleRow/Col need to be verified to be in [0, 1].
+ // Otherwise a {@link RuntimeException} will be raised in {@link ScaledLayout}.
+ if (scaleRow < 0 || scaleRow > 1) {
+ Log.i(
+ TAG,
+ "The vertical position of the anchor point should be at the range of 0 and 1"
+ + " but "
+ + scaleRow);
+ scaleRow = Math.max(0, Math.min(scaleRow, 1));
+ }
+ if (scaleCol < 0 || scaleCol > 1) {
+ Log.i(
+ TAG,
+ "The horizontal position of the anchor point should be at the range of 0 and"
+ + " 1 but "
+ + scaleCol);
+ scaleCol = Math.max(0, Math.min(scaleCol, 1));
+ }
+ int gravity = Gravity.CENTER;
+ int horizontalMode = captionWindow.anchorId % ANCHOR_MODE_DIVIDER;
+ int verticalMode = captionWindow.anchorId / ANCHOR_MODE_DIVIDER;
+ float scaleStartRow = 0;
+ float scaleEndRow = 1;
+ float scaleStartCol = 0;
+ float scaleEndCol = 1;
+ switch (horizontalMode) {
+ case ANCHOR_HORIZONTAL_MODE_LEFT:
+ gravity = Gravity.LEFT;
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_NORMAL);
+ scaleStartCol = scaleCol;
+ break;
+ case ANCHOR_HORIZONTAL_MODE_CENTER:
+ float gap = Math.min(1 - scaleCol, scaleCol);
+
+ // Since all TV sets use left text alignment instead of center text alignment
+ // for this case, we follow the industry convention if possible.
+ int columnCount = captionWindow.columnCount + 1;
+ if (isKoreanLanguageTrack()) {
+ columnCount /= 2;
+ }
+ columnCount = Math.min(getScreenColumnCount(), columnCount);
+ StringBuilder widestTextBuilder = new StringBuilder();
+ for (int i = 0; i < columnCount; ++i) {
+ widestTextBuilder.append(mWidestChar);
+ }
+ Paint paint = new Paint();
+ paint.setTypeface(mCaptionStyleCompat.typeface);
+ paint.setTextSize(mTextSize);
+ float maxWindowWidth = paint.measureText(widestTextBuilder.toString());
+ float halfMaxWidthScale =
+ mCaptionLayout.getWidth() > 0
+ ? maxWindowWidth / 2.0f / (mCaptionLayout.getWidth() * 0.8f)
+ : 0.0f;
+ if (halfMaxWidthScale > 0f && halfMaxWidthScale < scaleCol) {
+ // Calculate the expected max window size based on the column count of the
+ // caption window multiplied by average alphabets char width, then align the
+ // left side of the window with the left side of the expected max window.
+ gravity = Gravity.LEFT;
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_NORMAL);
+ scaleStartCol = scaleCol - halfMaxWidthScale;
+ scaleEndCol = 1.0f;
+ } else {
+ // The gap will be the minimum distance value of the distances from both
+ // horizontal end points to the anchor point.
+ // If scaleCol <= 0.5, the range of scaleCol is [0, the anchor point * 2].
+ // If scaleCol > 0.5, the range of scaleCol is [(1 - the anchor point) * 2, 1].
+ // The anchor point is located at the horizontal center of the window in both
+ // cases.
+ gravity = Gravity.CENTER_HORIZONTAL;
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_CENTER);
+ scaleStartCol = scaleCol - gap;
+ scaleEndCol = scaleCol + gap;
+ }
+ break;
+ case ANCHOR_HORIZONTAL_MODE_RIGHT:
+ gravity = Gravity.RIGHT;
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_OPPOSITE);
+ scaleEndCol = scaleCol;
+ break;
+ }
+ switch (verticalMode) {
+ case ANCHOR_VERTICAL_MODE_TOP:
+ gravity |= Gravity.TOP;
+ scaleStartRow = scaleRow;
+ break;
+ case ANCHOR_VERTICAL_MODE_CENTER:
+ gravity |= Gravity.CENTER_VERTICAL;
+
+ // See the above comment.
+ float gap = Math.min(1 - scaleRow, scaleRow);
+ scaleStartRow = scaleRow - gap;
+ scaleEndRow = scaleRow + gap;
+ break;
+ case ANCHOR_VERTICAL_MODE_BOTTOM:
+ gravity |= Gravity.BOTTOM;
+ scaleEndRow = scaleRow;
+ break;
+ }
+ mCaptionLayout.addOrUpdateViewToSafeTitleArea(
+ this,
+ new ScaledLayout.ScaledLayoutParams(
+ scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol));
+ setCaptionWindowId(captionWindow.id);
+ setRowLimit(captionWindow.rowCount);
+ setGravity(gravity);
+ setWindowStyle(captionWindow.windowStyle);
+ if (mWindowJustify == CaptionWindowAttr.JUSTIFY_CENTER) {
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_CENTER);
+ }
+ if (captionWindow.visible) {
+ show();
+ } else {
+ hide();
+ }
+ }
+
+ @Override
+ public void onLayoutChange(
+ View v,
+ int left,
+ int top,
+ int right,
+ int bottom,
+ int oldLeft,
+ int oldTop,
+ int oldRight,
+ int oldBottom) {
+ int width = right - left;
+ int height = bottom - top;
+ if (width != mLastCaptionLayoutWidth || height != mLastCaptionLayoutHeight) {
+ mLastCaptionLayoutWidth = width;
+ mLastCaptionLayoutHeight = height;
+ updateTextSize();
+ }
+ }
+
+ private boolean isKoreanLanguageTrack() {
+ return mCaptionLayout != null
+ && mCaptionLayout.getCaptionTrack() != null
+ && mCaptionLayout.getCaptionTrack().language != null
+ && "KOR".compareToIgnoreCase(mCaptionLayout.getCaptionTrack().language) == 0;
+ }
+
+ private boolean isWideAspectRatio() {
+ return mCaptionLayout != null
+ && mCaptionLayout.getCaptionTrack() != null
+ && mCaptionLayout.getCaptionTrack().wideAspectRatio;
+ }
+
+ private void updateWidestChar() {
+ if (isKoreanLanguageTrack()) {
+ mWidestChar = KOR_ALPHABET;
+ } else {
+ Paint paint = new Paint();
+ paint.setTypeface(mCaptionStyleCompat.typeface);
+ Charset latin1 = Charset.forName("ISO-8859-1");
+ float widestCharWidth = 0f;
+ for (int i = 0; i < 256; ++i) {
+ String ch = new String(new byte[] {(byte) i}, latin1);
+ float charWidth = paint.measureText(ch);
+ if (widestCharWidth < charWidth) {
+ widestCharWidth = charWidth;
+ mWidestChar = ch;
+ }
+ }
+ }
+ updateTextSize();
+ }
+
+ private void updateTextSize() {
+ if (mCaptionLayout == null) return;
+
+ // Calculate text size based on the max window size.
+ StringBuilder widestTextBuilder = new StringBuilder();
+ int screenColumnCount = getScreenColumnCount();
+ for (int i = 0; i < screenColumnCount; ++i) {
+ widestTextBuilder.append(mWidestChar);
+ }
+ String widestText = widestTextBuilder.toString();
+ Paint paint = new Paint();
+ paint.setTypeface(mCaptionStyleCompat.typeface);
+ float startFontSize = 0f;
+ float endFontSize = 255f;
+ Rect boundRect = new Rect();
+ while (startFontSize < endFontSize) {
+ float testTextSize = (startFontSize + endFontSize) / 2f;
+ paint.setTextSize(testTextSize);
+ float width = paint.measureText(widestText);
+ paint.getTextBounds(widestText, 0, widestText.length(), boundRect);
+ float height = boundRect.height() + width - boundRect.width();
+ // According to CEA-708B Section 9.13, the height of standard font size shouldn't taller
+ // than 1/15 of the height of the safe-title area, and the width shouldn't wider than
+ // 1/{@code getScreenColumnCount()} of the width of the safe-title area.
+ if (mCaptionLayout.getWidth() * 0.8f > width
+ && mCaptionLayout.getHeight() * 0.8f / MAX_ROW_COUNT > height) {
+ startFontSize = testTextSize + 0.01f;
+ } else {
+ endFontSize = testTextSize - 0.01f;
+ }
+ }
+ mTextSize = endFontSize * mFontScale;
+ paint.setTextSize(mTextSize);
+ float whiteSpaceWidth = paint.measureText(" ");
+ mSubtitleView.setWhiteSpaceWidth(whiteSpaceWidth);
+ mSubtitleView.setTextSize(mTextSize);
+ }
+
+ private int getScreenColumnCount() {
+ float screenAspectRatio = (float) mCaptionLayout.getWidth() / mCaptionLayout.getHeight();
+ boolean isWideAspectRationScreen = screenAspectRatio > WIDE_SCREEN_ASPECT_RATIO_THRESHOLD;
+ if (isKoreanLanguageTrack()) {
+ // Each korean character consumes two slots.
+ if (isWideAspectRationScreen || isWideAspectRatio()) {
+ return KR_MAX_COLUMN_COUNT_16_9 / 2;
+ } else {
+ return KR_MAX_COLUMN_COUNT_4_3 / 2;
+ }
+ } else {
+ if (isWideAspectRationScreen || isWideAspectRatio()) {
+ return US_MAX_COLUMN_COUNT_16_9;
+ } else {
+ return US_MAX_COLUMN_COUNT_4_3;
+ }
+ }
+ }
+
+ public void removeFromCaptionView() {
+ if (mCaptionLayout != null) {
+ mCaptionLayout.removeViewFromSafeTitleArea(this);
+ mCaptionLayout.removeOnLayoutChangeListener(this);
+ mCaptionLayout = null;
+ }
+ }
+
+ public void setText(String text) {
+ updateText(text, false);
+ }
+
+ public void appendText(String text) {
+ updateText(text, true);
+ }
+
+ public void clearText() {
+ mBuilder.clear();
+ mSubtitleView.setText("");
+ }
+
+ private void updateText(String text, boolean appended) {
+ if (!appended) {
+ mBuilder.clear();
+ }
+ if (text != null && text.length() > 0) {
+ int length = mBuilder.length();
+ mBuilder.append(text);
+ for (CharacterStyle characterStyle : mCharacterStyles) {
+ mBuilder.setSpan(
+ characterStyle,
+ length,
+ mBuilder.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ String[] lines = TextUtils.split(mBuilder.toString(), "\n");
+
+ // Truncate text not to exceed the row limit.
+ // Plus one here since the range of the rows is [0, mRowLimit].
+ int startRow = Math.max(0, lines.length - (mRowLimit + 1));
+ String truncatedText =
+ TextUtils.join("\n", Arrays.copyOfRange(lines, startRow, lines.length));
+ mBuilder.delete(0, mBuilder.length() - truncatedText.length());
+ mCurrentTextRow = lines.length - startRow - 1;
+
+ // Trim the buffer first then set text to {@link SubtitleView}.
+ int start = 0, last = mBuilder.length() - 1;
+ int end = last;
+ while ((start <= end) && (mBuilder.charAt(start) <= ' ')) {
+ ++start;
+ }
+ while (start - 1 >= 0 && start <= end && mBuilder.charAt(start - 1) != '\n') {
+ --start;
+ }
+ while ((end >= start) && (mBuilder.charAt(end) <= ' ')) {
+ --end;
+ }
+ if (start == 0 && end == last) {
+ mSubtitleView.setPrefixSpaces(getPrefixSpaces(mBuilder));
+ mSubtitleView.setText(mBuilder);
+ } else {
+ SpannableStringBuilder trim = new SpannableStringBuilder();
+ trim.append(mBuilder);
+ if (end < last) {
+ trim.delete(end + 1, last + 1);
+ }
+ if (start > 0) {
+ trim.delete(0, start);
+ }
+ mSubtitleView.setPrefixSpaces(getPrefixSpaces(trim));
+ mSubtitleView.setText(trim);
+ }
+ }
+
+ private static ArrayList<Integer> getPrefixSpaces(SpannableStringBuilder builder) {
+ ArrayList<Integer> prefixSpaces = new ArrayList<>();
+ String[] lines = TextUtils.split(builder.toString(), "\n");
+ for (String line : lines) {
+ int start = 0;
+ while (start < line.length() && line.charAt(start) <= ' ') {
+ start++;
+ }
+ prefixSpaces.add(start);
+ }
+ return prefixSpaces;
+ }
+
+ public void setRowLimit(int rowLimit) {
+ if (rowLimit < 0) {
+ throw new IllegalArgumentException("A rowLimit should have a positive number");
+ }
+ mRowLimit = rowLimit;
+ }
+
+ private void setWindowStyle(int windowStyle) {
+ // TODO: Set other attributes of window style. Like fill opacity and fill color.
+ switch (windowStyle) {
+ case 2:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 3:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_CENTER;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 4:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 5:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 6:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_CENTER;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 7:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_TOP_TO_BOTTOM;
+ break;
+ default:
+ if (windowStyle != 0 && windowStyle != 1) {
+ Log.e(TAG, "Error predefined window style:" + windowStyle);
+ }
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/cc/Cea708Parser.java b/tuner/src/com/android/tv/tuner/cc/Cea708Parser.java
new file mode 100644
index 00000000..4e080276
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/cc/Cea708Parser.java
@@ -0,0 +1,922 @@
+/*
+ * 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.cc;
+
+import android.os.SystemClock;
+import android.support.annotation.IntDef;
+import android.util.Log;
+import android.util.SparseIntArray;
+import com.android.tv.tuner.data.Cea708Data;
+import com.android.tv.tuner.data.Cea708Data.CaptionColor;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenColor;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenLocation;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindow;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr;
+import com.android.tv.tuner.data.Cea708Data.CcPacket;
+import com.android.tv.tuner.util.ByteArrayBuffer;
+import java.io.UnsupportedEncodingException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.TreeSet;
+
+/**
+ * A class for parsing CEA-708, which is the standard for closed captioning for ATSC DTV.
+ *
+ * <p>ATSC DTV closed caption data are carried on picture user data of video streams. This class
+ * starts to parse from picture user data payload, so extraction process of user_data from video
+ * streams is up to outside of this code.
+ *
+ * <p>There are 4 steps to decode user_data to provide closed caption services.
+ *
+ * <h3>Step 1. user_data -&gt; CcPacket ({@link #parseClosedCaption} method)</h3>
+ *
+ * <p>First, user_data consists of cc_data packets, which are 3-byte segments. Here, CcPacket is a
+ * collection of cc_data packets in a frame along with same presentation timestamp. Because cc_data
+ * packets must be reassembled in the frame display order, CcPackets are reordered.
+ *
+ * <h3>Step 2. CcPacket -&gt; DTVCC packet ({@link #parseCcPacket} method)</h3>
+ *
+ * <p>Each cc_data packet has a one byte for declaring a type of itself and data validity, and the
+ * subsequent two bytes for input data of a DTVCC packet. There are 4 types for cc_data packet.
+ * We're interested in DTVCC_PACKET_START(type 3) and DTVCC_PACKET_DATA(type 2). Each DTVCC packet
+ * begins with DTVCC_PACKET_START(type 3) and the following cc_data packets which has
+ * DTVCC_PACKET_DATA(type 2) are appended into the DTVCC packet being assembled.
+ *
+ * <h3>Step 3. DTVCC packet -&gt; Service Blocks ({@link #parseDtvCcPacket} method)</h3>
+ *
+ * <p>A DTVCC packet consists of multiple service blocks. Each service block represents a caption
+ * track and has a service number, which ranges from 1 to 63, that denotes caption track identity.
+ * In here, we listen at most one chosen caption track by {@link #mListenServiceNumber}. Otherwise,
+ * just skip the other service blocks.
+ *
+ * <h3>Step 4. Interpreting Service Block Data ({@link #parseServiceBlockData}, {@code parseXX}, and
+ * {@link #parseExt1} methods)</h3>
+ *
+ * <p>Service block data is actual caption stream. it looks similar to telnet. It uses most parts of
+ * ASCII table and consists of specially defined commands and some ASCII control codes which work in
+ * a behavior slightly different from their original purpose. ASCII control codes and caption
+ * commands are explicit instructions that control the state of a closed caption service and the
+ * other ASCII and text codes are implicit instructions that send their characters to buffer.
+ *
+ * <p>There are 4 main code groups and 4 extended code groups. Both the range of code groups are the
+ * same as the range of a byte.
+ *
+ * <p>4 main code groups: C0, C1, G0, G1 <br>
+ * 4 extended code groups: C2, C3, G2, G3
+ *
+ * <p>Each code group has its own handle method. For example, {@link #parseC0} handles C0 code group
+ * and so on. And {@link #parseServiceBlockData} method maps a stream on the main code groups while
+ * {@link #parseExt1} method maps on the extended code groups.
+ *
+ * <p>The main code groups:
+ *
+ * <ul>
+ * <li>C0 - contains modified ASCII control codes. It is not intended by CEA-708 but Korea TTA
+ * standard for ATSC CC uses P16 character heavily, which is unclear entity in CEA-708 doc,
+ * even for the alphanumeric characters instead of ASCII characters.
+ * <li>C1 - contains the caption commands. There are 3 categories of a caption command.
+ * <ul>
+ * <li>Window commands: The window commands control a caption window which is addressable
+ * area being with in the Safe title area. (CWX, CLW, DSW, HDW, TGW, DLW, SWA, DFX)
+ * <li>Pen commands: Th pen commands control text style and location. (SPA, SPC, SPL)
+ * <li>Job commands: The job commands make a delay and recover from the delay. (DLY, DLC,
+ * RST)
+ * </ul>
+ * <li>G0 - same as printable ASCII character set except music note character.
+ * <li>G1 - same as ISO 8859-1 Latin 1 character set.
+ * </ul>
+ *
+ * <p>Most of the extended code groups are being skipped.
+ */
+public class Cea708Parser {
+ private static final String TAG = "Cea708Parser";
+ private static final boolean DEBUG = false;
+
+ // According to CEA-708B, the maximum value of closed caption bandwidth is 9600bps.
+ private static final int MAX_ALLOCATED_SIZE = 9600 / 8;
+ private static final String MUSIC_NOTE_CHAR =
+ new String("\u266B".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
+
+ // The following values are denoting the type of closed caption data.
+ // See CEA-708B section 4.4.1.
+ private static final int CC_TYPE_DTVCC_PACKET_START = 3;
+ private static final int CC_TYPE_DTVCC_PACKET_DATA = 2;
+
+ // The following values are defined in CEA-708B Figure 4 and 6.
+ private static final int DTVCC_MAX_PACKET_SIZE = 64;
+ private static final int DTVCC_PACKET_SIZE_SCALE_FACTOR = 2;
+ private static final int DTVCC_EXTENDED_SERVICE_NUMBER_POINT = 7;
+
+ // The following values are for seeking closed caption tracks.
+ private static final int DISCOVERY_PERIOD_MS = 10000; // 10 sec
+ private static final int DISCOVERY_NUM_BYTES_THRESHOLD = 10; // 10 bytes
+ private static final int DISCOVERY_CC_SERVICE_NUMBER_START = 1; // CC1
+ private static final int DISCOVERY_CC_SERVICE_NUMBER_END = 4; // CC4
+
+ private final ByteArrayBuffer mDtvCcPacket = new ByteArrayBuffer(MAX_ALLOCATED_SIZE);
+ private final TreeSet<CcPacket> mCcPackets = new TreeSet<>();
+ private final StringBuffer mBuffer = new StringBuffer();
+ private final SparseIntArray mDiscoveredNumBytes = new SparseIntArray(); // per service number
+ private long mLastDiscoveryLaunchedMs = SystemClock.elapsedRealtime();
+ private int mCommand = 0;
+ private int mListenServiceNumber = 0;
+ private boolean mDtvCcPacking = false;
+ private boolean mFirstServiceNumberDiscovered;
+
+ // Assign a dummy listener in order to avoid null checks.
+ private OnCea708ParserListener mListener =
+ new OnCea708ParserListener() {
+ @Override
+ public void emitEvent(CaptionEvent event) {
+ // do nothing
+ }
+
+ @Override
+ public void discoverServiceNumber(int serviceNumber) {
+ // do nothing
+ }
+ };
+
+ /**
+ * {@link Cea708Parser} emits caption event of three different types. {@link
+ * OnCea708ParserListener#emitEvent} is invoked with the parameter {@link CaptionEvent} to pass
+ * all the results to an observer of the decoding process.
+ *
+ * <p>{@link CaptionEvent#type} determines the type of the result and {@link CaptionEvent#obj}
+ * contains the output value of a caption event. The observer must do the casting to the
+ * corresponding type.
+ *
+ * <ul>
+ * <li>{@code CAPTION_EMIT_TYPE_BUFFER}: Passes a caption text buffer to a observer. {@code
+ * obj} must be of {@link String}.
+ * <li>{@code CAPTION_EMIT_TYPE_CONTROL}: Passes a caption character control code to a
+ * observer. {@code obj} must be of {@link Character}.
+ * <li>{@code CAPTION_EMIT_TYPE_CLEAR_COMMAND}: Passes a clear command to a observer. {@code
+ * obj} must be {@code NULL}.
+ * </ul>
+ */
+ @IntDef({
+ CAPTION_EMIT_TYPE_BUFFER,
+ CAPTION_EMIT_TYPE_CONTROL,
+ CAPTION_EMIT_TYPE_COMMAND_CWX,
+ CAPTION_EMIT_TYPE_COMMAND_CLW,
+ CAPTION_EMIT_TYPE_COMMAND_DSW,
+ CAPTION_EMIT_TYPE_COMMAND_HDW,
+ CAPTION_EMIT_TYPE_COMMAND_TGW,
+ CAPTION_EMIT_TYPE_COMMAND_DLW,
+ CAPTION_EMIT_TYPE_COMMAND_DLY,
+ CAPTION_EMIT_TYPE_COMMAND_DLC,
+ CAPTION_EMIT_TYPE_COMMAND_RST,
+ CAPTION_EMIT_TYPE_COMMAND_SPA,
+ CAPTION_EMIT_TYPE_COMMAND_SPC,
+ CAPTION_EMIT_TYPE_COMMAND_SPL,
+ CAPTION_EMIT_TYPE_COMMAND_SWA,
+ CAPTION_EMIT_TYPE_COMMAND_DFX
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface CaptionEmitType {}
+
+ public static final int CAPTION_EMIT_TYPE_BUFFER = 1;
+ public static final int CAPTION_EMIT_TYPE_CONTROL = 2;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_CWX = 3;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_CLW = 4;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DSW = 5;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_HDW = 6;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_TGW = 7;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DLW = 8;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DLY = 9;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DLC = 10;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_RST = 11;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_SPA = 12;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_SPC = 13;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_SPL = 14;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_SWA = 15;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DFX = 16;
+
+ public interface OnCea708ParserListener {
+ void emitEvent(CaptionEvent event);
+
+ void discoverServiceNumber(int serviceNumber);
+ }
+
+ public void setListener(OnCea708ParserListener listener) {
+ if (listener != null) {
+ mListener = listener;
+ }
+ }
+
+ public void clear() {
+ mDtvCcPacket.clear();
+ mCcPackets.clear();
+ mBuffer.setLength(0);
+ mDiscoveredNumBytes.clear();
+ mCommand = 0;
+ mDtvCcPacking = false;
+ }
+
+ public void setListenServiceNumber(int serviceNumber) {
+ mListenServiceNumber = serviceNumber;
+ }
+
+ private void emitCaptionEvent(CaptionEvent captionEvent) {
+ // Emit the existing string buffer before a new event is arrived.
+ emitCaptionBuffer();
+ mListener.emitEvent(captionEvent);
+ }
+
+ private void emitCaptionBuffer() {
+ if (mBuffer.length() > 0) {
+ mListener.emitEvent(new CaptionEvent(CAPTION_EMIT_TYPE_BUFFER, mBuffer.toString()));
+ mBuffer.setLength(0);
+ }
+ }
+
+ // Step 1. user_data -> CcPacket ({@link #parseClosedCaption} method)
+ public void parseClosedCaption(ByteBuffer data, long framePtsUs) {
+ int ccCount = data.limit() / 3;
+ byte[] ccBytes = new byte[3 * ccCount];
+ for (int i = 0; i < 3 * ccCount; i++) {
+ ccBytes[i] = data.get(i);
+ }
+ CcPacket ccPacket = new CcPacket(ccBytes, ccCount, framePtsUs);
+ mCcPackets.add(ccPacket);
+ }
+
+ public boolean processClosedCaptions(long framePtsUs) {
+ // To get the sorted cc packets that have lower frame pts than current frame pts,
+ // the following offset divides off the lower side of the packets.
+ CcPacket offsetPacket = new CcPacket(new byte[0], 0, framePtsUs);
+ offsetPacket = mCcPackets.lower(offsetPacket);
+ boolean processed = false;
+ if (offsetPacket != null) {
+ while (!mCcPackets.isEmpty() && offsetPacket.compareTo(mCcPackets.first()) >= 0) {
+ CcPacket packet = mCcPackets.pollFirst();
+ parseCcPacket(packet);
+ processed = true;
+ }
+ }
+ return processed;
+ }
+
+ // Step 2. CcPacket -> DTVCC packet ({@link #parseCcPacket} method)
+ private void parseCcPacket(CcPacket ccPacket) {
+ // For the details of cc packet, see ATSC TSG-676 - Table A8.
+ byte[] bytes = ccPacket.bytes;
+ int pos = 0;
+ for (int i = 0; i < ccPacket.ccCount; ++i) {
+ boolean ccValid = (bytes[pos] & 0x04) != 0;
+ int ccType = bytes[pos] & 0x03;
+
+ // The dtvcc should be considered complete:
+ // - if either ccValid is set and ccType is 3
+ // - or ccValid is clear and ccType is 2 or 3.
+ if (ccValid) {
+ if (ccType == CC_TYPE_DTVCC_PACKET_START) {
+ if (mDtvCcPacking) {
+ parseDtvCcPacket(mDtvCcPacket.buffer(), mDtvCcPacket.length());
+ mDtvCcPacket.clear();
+ }
+ mDtvCcPacking = true;
+ mDtvCcPacket.append(bytes[pos + 1]);
+ mDtvCcPacket.append(bytes[pos + 2]);
+ } else if (mDtvCcPacking && ccType == CC_TYPE_DTVCC_PACKET_DATA) {
+ mDtvCcPacket.append(bytes[pos + 1]);
+ mDtvCcPacket.append(bytes[pos + 2]);
+ }
+ } else {
+ if ((ccType == CC_TYPE_DTVCC_PACKET_START || ccType == CC_TYPE_DTVCC_PACKET_DATA)
+ && mDtvCcPacking) {
+ mDtvCcPacking = false;
+ parseDtvCcPacket(mDtvCcPacket.buffer(), mDtvCcPacket.length());
+ mDtvCcPacket.clear();
+ }
+ }
+ pos += 3;
+ }
+ }
+
+ // Step 3. DTVCC packet -> Service Blocks ({@link #parseDtvCcPacket} method)
+ private void parseDtvCcPacket(byte[] data, int limit) {
+ // For the details of DTVCC packet, see CEA-708B Figure 4.
+ int pos = 0;
+ int packetSize = data[pos] & 0x3f;
+ if (packetSize == 0) {
+ packetSize = DTVCC_MAX_PACKET_SIZE;
+ }
+ int calculatedPacketSize = packetSize * DTVCC_PACKET_SIZE_SCALE_FACTOR;
+ if (limit != calculatedPacketSize) {
+ return;
+ }
+ ++pos;
+ int len = pos + calculatedPacketSize;
+ while (pos < len) {
+ // For the details of Service Block, see CEA-708B Figure 5 and 6.
+ int serviceNumber = (data[pos] & 0xe0) >> 5;
+ int blockSize = data[pos] & 0x1f;
+ ++pos;
+ if (serviceNumber == DTVCC_EXTENDED_SERVICE_NUMBER_POINT) {
+ serviceNumber = (data[pos] & 0x3f);
+ ++pos;
+
+ // Return if invalid service number
+ if (serviceNumber < DTVCC_EXTENDED_SERVICE_NUMBER_POINT) {
+ return;
+ }
+ }
+ if (pos + blockSize > limit) {
+ return;
+ }
+
+ // Send parsed service number in order to find unveiled closed caption tracks which
+ // are not specified in any ATSC PSIP sections. Since some broadcasts send empty closed
+ // caption tracks, it detects the proper closed caption tracks by counting the number of
+ // bytes sent with the same service number during a discovery period.
+ // The viewer in most TV sets chooses between CC1, CC2, CC3, CC4 to view different
+ // language captions. Therefore, only CC1, CC2, CC3, CC4 are allowed to be reported.
+ if (blockSize > 0
+ && serviceNumber >= DISCOVERY_CC_SERVICE_NUMBER_START
+ && serviceNumber <= DISCOVERY_CC_SERVICE_NUMBER_END) {
+ mDiscoveredNumBytes.put(
+ serviceNumber, blockSize + mDiscoveredNumBytes.get(serviceNumber, 0));
+ }
+ if (mLastDiscoveryLaunchedMs + DISCOVERY_PERIOD_MS < SystemClock.elapsedRealtime()
+ || !mFirstServiceNumberDiscovered) {
+ for (int i = 0; i < mDiscoveredNumBytes.size(); ++i) {
+ int discoveredNumBytes = mDiscoveredNumBytes.valueAt(i);
+ if (discoveredNumBytes >= DISCOVERY_NUM_BYTES_THRESHOLD) {
+ int discoveredServiceNumber = mDiscoveredNumBytes.keyAt(i);
+ mListener.discoverServiceNumber(discoveredServiceNumber);
+ mFirstServiceNumberDiscovered = true;
+ }
+ }
+ mDiscoveredNumBytes.clear();
+ mLastDiscoveryLaunchedMs = SystemClock.elapsedRealtime();
+ }
+
+ // Skip current service block if either there is no block data or the service number
+ // is not same as listening service number.
+ if (blockSize == 0 || serviceNumber != mListenServiceNumber) {
+ pos += blockSize;
+ continue;
+ }
+
+ // From this point, starts to read DTVCC coding layer.
+ // First, identify code groups, which is defined in CEA-708B Section 7.1.
+ int blockLimit = pos + blockSize;
+ while (pos < blockLimit) {
+ pos = parseServiceBlockData(data, pos);
+ }
+
+ // Emit the buffer after reading codes.
+ emitCaptionBuffer();
+ pos = blockLimit;
+ }
+ }
+
+ // Step 4. Main code groups
+ private int parseServiceBlockData(byte[] data, int pos) {
+ // For the details of the ranges of DTVCC code groups, see CEA-708B Table 6.
+ mCommand = data[pos] & 0xff;
+ ++pos;
+ if (mCommand == Cea708Data.CODE_C0_EXT1) {
+ pos = parseExt1(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_C0_RANGE_START
+ && mCommand <= Cea708Data.CODE_C0_RANGE_END) {
+ pos = parseC0(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_C1_RANGE_START
+ && mCommand <= Cea708Data.CODE_C1_RANGE_END) {
+ pos = parseC1(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_G0_RANGE_START
+ && mCommand <= Cea708Data.CODE_G0_RANGE_END) {
+ pos = parseG0(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_G1_RANGE_START
+ && mCommand <= Cea708Data.CODE_G1_RANGE_END) {
+ pos = parseG1(data, pos);
+ }
+ return pos;
+ }
+
+ private int parseC0(byte[] data, int pos) {
+ // For the details of C0 code group, see CEA-708B Section 7.4.1.
+ // CL Group: C0 Subset of ASCII Control codes
+ if (mCommand >= Cea708Data.CODE_C0_SKIP2_RANGE_START
+ && mCommand <= Cea708Data.CODE_C0_SKIP2_RANGE_END) {
+ if (mCommand == Cea708Data.CODE_C0_P16) {
+ // TODO : P16 escapes next two bytes for the large character maps.(no standard rule)
+ // TODO : For korea broadcasting, express whole letters by using this.
+ try {
+ if (data[pos] == 0) {
+ mBuffer.append((char) data[pos + 1]);
+ } else {
+ String value = new String(Arrays.copyOfRange(data, pos, pos + 2), "EUC-KR");
+ mBuffer.append(value);
+ }
+ } catch (UnsupportedEncodingException e) {
+ Log.e(TAG, "P16 Code - Could not find supported encoding", e);
+ }
+ }
+ pos += 2;
+ } else if (mCommand >= Cea708Data.CODE_C0_SKIP1_RANGE_START
+ && mCommand <= Cea708Data.CODE_C0_SKIP1_RANGE_END) {
+ ++pos;
+ } else {
+ // NUL, BS, FF, CR interpreted as they are in ASCII control codes.
+ // HCR moves the pen location to th beginning of the current line and deletes contents.
+ // FF clears the screen and moves the pen location to (0,0).
+ // ETX is the NULL command which is used to flush text to the current window when no
+ // other command is pending.
+ switch (mCommand) {
+ case Cea708Data.CODE_C0_NUL:
+ break;
+ case Cea708Data.CODE_C0_ETX:
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand));
+ break;
+ case Cea708Data.CODE_C0_BS:
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand));
+ break;
+ case Cea708Data.CODE_C0_FF:
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand));
+ break;
+ case Cea708Data.CODE_C0_CR:
+ mBuffer.append('\n');
+ break;
+ case Cea708Data.CODE_C0_HCR:
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand));
+ break;
+ default:
+ break;
+ }
+ }
+ return pos;
+ }
+
+ private int parseC1(byte[] data, int pos) {
+ // For the details of C1 code group, see CEA-708B Section 8.10.
+ // CR Group: C1 Caption Control Codes
+ switch (mCommand) {
+ case Cea708Data.CODE_C1_CW0:
+ case Cea708Data.CODE_C1_CW1:
+ case Cea708Data.CODE_C1_CW2:
+ case Cea708Data.CODE_C1_CW3:
+ case Cea708Data.CODE_C1_CW4:
+ case Cea708Data.CODE_C1_CW5:
+ case Cea708Data.CODE_C1_CW6:
+ case Cea708Data.CODE_C1_CW7:
+ {
+ // SetCurrentWindow0-7
+ int windowId = mCommand - Cea708Data.CODE_C1_CW0;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_CWX, windowId));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand CWX windowId: %d", windowId));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_CLW:
+ {
+ // ClearWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_CLW, windowBitmap));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format("CaptionCommand CLW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_DSW:
+ {
+ // DisplayWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DSW, windowBitmap));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format("CaptionCommand DSW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_HDW:
+ {
+ // HideWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_HDW, windowBitmap));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format("CaptionCommand HDW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_TGW:
+ {
+ // ToggleWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_TGW, windowBitmap));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format("CaptionCommand TGW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_DLW:
+ {
+ // DeleteWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLW, windowBitmap));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format("CaptionCommand DLW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_DLY:
+ {
+ // Delay
+ int tenthsOfSeconds = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(
+ new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLY, tenthsOfSeconds));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "CaptionCommand DLY %d tenths of seconds",
+ tenthsOfSeconds));
+ }
+ break;
+ }
+ case Cea708Data.CODE_C1_DLC:
+ {
+ // DelayCancel
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLC, null));
+ if (DEBUG) {
+ Log.d(TAG, "CaptionCommand DLC");
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_RST:
+ {
+ // Reset
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_RST, null));
+ if (DEBUG) {
+ Log.d(TAG, "CaptionCommand RST");
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_SPA:
+ {
+ // SetPenAttributes
+ int textTag = (data[pos] & 0xf0) >> 4;
+ int penSize = data[pos] & 0x03;
+ int penOffset = (data[pos] & 0x0c) >> 2;
+ boolean italic = (data[pos + 1] & 0x80) != 0;
+ boolean underline = (data[pos + 1] & 0x40) != 0;
+ int edgeType = (data[pos + 1] & 0x38) >> 3;
+ int fontTag = data[pos + 1] & 0x7;
+ pos += 2;
+ emitCaptionEvent(
+ new CaptionEvent(
+ CAPTION_EMIT_TYPE_COMMAND_SPA,
+ new CaptionPenAttr(
+ penSize, penOffset, textTag, fontTag, edgeType,
+ underline, italic)));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "CaptionCommand SPA penSize: %d, penOffset: %d, textTag: %d, "
+ + "fontTag: %d, edgeType: %d, underline: %s, italic: %s",
+ penSize, penOffset, textTag, fontTag, edgeType, underline,
+ italic));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_SPC:
+ {
+ // SetPenColor
+ int opacity = (data[pos] & 0xc0) >> 6;
+ int red = (data[pos] & 0x30) >> 4;
+ int green = (data[pos] & 0x0c) >> 2;
+ int blue = data[pos] & 0x03;
+ CaptionColor foregroundColor = new CaptionColor(opacity, red, green, blue);
+ ++pos;
+ opacity = (data[pos] & 0xc0) >> 6;
+ red = (data[pos] & 0x30) >> 4;
+ green = (data[pos] & 0x0c) >> 2;
+ blue = data[pos] & 0x03;
+ CaptionColor backgroundColor = new CaptionColor(opacity, red, green, blue);
+ ++pos;
+ red = (data[pos] & 0x30) >> 4;
+ green = (data[pos] & 0x0c) >> 2;
+ blue = data[pos] & 0x03;
+ CaptionColor edgeColor =
+ new CaptionColor(CaptionColor.OPACITY_SOLID, red, green, blue);
+ ++pos;
+ emitCaptionEvent(
+ new CaptionEvent(
+ CAPTION_EMIT_TYPE_COMMAND_SPC,
+ new CaptionPenColor(
+ foregroundColor, backgroundColor, edgeColor)));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "CaptionCommand SPC foregroundColor %s backgroundColor %s edgeColor %s",
+ foregroundColor, backgroundColor, edgeColor));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_SPL:
+ {
+ // SetPenLocation
+ // column is normally 0-31 for 4:3 formats, and 0-41 for 16:9 formats
+ int row = data[pos] & 0x0f;
+ int column = data[pos + 1] & 0x3f;
+ pos += 2;
+ emitCaptionEvent(
+ new CaptionEvent(
+ CAPTION_EMIT_TYPE_COMMAND_SPL,
+ new CaptionPenLocation(row, column)));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "CaptionCommand SPL row: %d, column: %d", row, column));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_SWA:
+ {
+ // SetWindowAttributes
+ int opacity = (data[pos] & 0xc0) >> 6;
+ int red = (data[pos] & 0x30) >> 4;
+ int green = (data[pos] & 0x0c) >> 2;
+ int blue = data[pos] & 0x03;
+ CaptionColor fillColor = new CaptionColor(opacity, red, green, blue);
+ int borderType = (data[pos + 1] & 0xc0) >> 6 | (data[pos + 2] & 0x80) >> 5;
+ red = (data[pos + 1] & 0x30) >> 4;
+ green = (data[pos + 1] & 0x0c) >> 2;
+ blue = data[pos + 1] & 0x03;
+ CaptionColor borderColor =
+ new CaptionColor(CaptionColor.OPACITY_SOLID, red, green, blue);
+ boolean wordWrap = (data[pos + 2] & 0x40) != 0;
+ int printDirection = (data[pos + 2] & 0x30) >> 4;
+ int scrollDirection = (data[pos + 2] & 0x0c) >> 2;
+ int justify = (data[pos + 2] & 0x03);
+ int effectSpeed = (data[pos + 3] & 0xf0) >> 4;
+ int effectDirection = (data[pos + 3] & 0x0c) >> 2;
+ int displayEffect = data[pos + 3] & 0x3;
+ pos += 4;
+ emitCaptionEvent(
+ new CaptionEvent(
+ CAPTION_EMIT_TYPE_COMMAND_SWA,
+ new CaptionWindowAttr(
+ fillColor,
+ borderColor,
+ borderType,
+ wordWrap,
+ printDirection,
+ scrollDirection,
+ justify,
+ effectDirection,
+ effectSpeed,
+ displayEffect)));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "CaptionCommand SWA fillColor: %s, borderColor: %s, borderType: %d"
+ + "wordWrap: %s, printDirection: %d, scrollDirection: %d, "
+ + "justify: %s, effectDirection: %d, effectSpeed: %d, "
+ + "displayEffect: %d",
+ fillColor,
+ borderColor,
+ borderType,
+ wordWrap,
+ printDirection,
+ scrollDirection,
+ justify,
+ effectDirection,
+ effectSpeed,
+ displayEffect));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_DF0:
+ case Cea708Data.CODE_C1_DF1:
+ case Cea708Data.CODE_C1_DF2:
+ case Cea708Data.CODE_C1_DF3:
+ case Cea708Data.CODE_C1_DF4:
+ case Cea708Data.CODE_C1_DF5:
+ case Cea708Data.CODE_C1_DF6:
+ case Cea708Data.CODE_C1_DF7:
+ {
+ // DefineWindow0-7
+ int windowId = mCommand - Cea708Data.CODE_C1_DF0;
+ boolean visible = (data[pos] & 0x20) != 0;
+ boolean rowLock = (data[pos] & 0x10) != 0;
+ boolean columnLock = (data[pos] & 0x08) != 0;
+ int priority = data[pos] & 0x07;
+ boolean relativePositioning = (data[pos + 1] & 0x80) != 0;
+ int anchorVertical = data[pos + 1] & 0x7f;
+ int anchorHorizontal = data[pos + 2] & 0xff;
+ int anchorId = (data[pos + 3] & 0xf0) >> 4;
+ int rowCount = data[pos + 3] & 0x0f;
+ int columnCount = data[pos + 4] & 0x3f;
+ int windowStyle = (data[pos + 5] & 0x38) >> 3;
+ int penStyle = data[pos + 5] & 0x07;
+ pos += 6;
+ emitCaptionEvent(
+ new CaptionEvent(
+ CAPTION_EMIT_TYPE_COMMAND_DFX,
+ new CaptionWindow(
+ windowId,
+ visible,
+ rowLock,
+ columnLock,
+ priority,
+ relativePositioning,
+ anchorVertical,
+ anchorHorizontal,
+ anchorId,
+ rowCount,
+ columnCount,
+ penStyle,
+ windowStyle)));
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "CaptionCommand DFx windowId: %d, priority: %d, columnLock: %s, "
+ + "rowLock: %s, visible: %s, anchorVertical: %d, "
+ + "relativePositioning: %s, anchorHorizontal: %d, "
+ + "rowCount: %d, anchorId: %d, columnCount: %d, penStyle: %d, "
+ + "windowStyle: %d",
+ windowId,
+ priority,
+ columnLock,
+ rowLock,
+ visible,
+ anchorVertical,
+ relativePositioning,
+ anchorHorizontal,
+ rowCount,
+ anchorId,
+ columnCount,
+ penStyle,
+ windowStyle));
+ }
+ break;
+ }
+
+ default:
+ break;
+ }
+ return pos;
+ }
+
+ private int parseG0(byte[] data, int pos) {
+ // For the details of G0 code group, see CEA-708B Section 7.4.3.
+ // GL Group: G0 Modified version of ANSI X3.4 Printable Character Set (ASCII)
+ if (mCommand == Cea708Data.CODE_G0_MUSICNOTE) {
+ // Music note.
+ mBuffer.append(MUSIC_NOTE_CHAR);
+ } else {
+ // Put ASCII code into buffer.
+ mBuffer.append((char) mCommand);
+ }
+ return pos;
+ }
+
+ private int parseG1(byte[] data, int pos) {
+ // For the details of G0 code group, see CEA-708B Section 7.4.4.
+ // GR Group: G1 ISO 8859-1 Latin 1 Characters
+ // Put ASCII Extended character set into buffer.
+ mBuffer.append((char) mCommand);
+ return pos;
+ }
+
+ // Step 4. Extended code groups
+ private int parseExt1(byte[] data, int pos) {
+ // For the details of EXT1 code group, see CEA-708B Section 7.2.
+ mCommand = data[pos] & 0xff;
+ ++pos;
+ if (mCommand >= Cea708Data.CODE_C2_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_RANGE_END) {
+ pos = parseC2(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_C3_RANGE_START
+ && mCommand <= Cea708Data.CODE_C3_RANGE_END) {
+ pos = parseC3(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_G2_RANGE_START
+ && mCommand <= Cea708Data.CODE_G2_RANGE_END) {
+ pos = parseG2(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_G3_RANGE_START
+ && mCommand <= Cea708Data.CODE_G3_RANGE_END) {
+ pos = parseG3(data, pos);
+ }
+ return pos;
+ }
+
+ private int parseC2(byte[] data, int pos) {
+ // For the details of C2 code group, see CEA-708B Section 7.4.7.
+ // Extended Miscellaneous Control Codes
+ // C2 Table : No commands as of CEA-708B. A decoder must skip.
+ if (mCommand >= Cea708Data.CODE_C2_SKIP0_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_SKIP0_RANGE_END) {
+ // Do nothing.
+ } else if (mCommand >= Cea708Data.CODE_C2_SKIP1_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_SKIP1_RANGE_END) {
+ ++pos;
+ } else if (mCommand >= Cea708Data.CODE_C2_SKIP2_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_SKIP2_RANGE_END) {
+ pos += 2;
+ } else if (mCommand >= Cea708Data.CODE_C2_SKIP3_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_SKIP3_RANGE_END) {
+ pos += 3;
+ }
+ return pos;
+ }
+
+ private int parseC3(byte[] data, int pos) {
+ // For the details of C3 code group, see CEA-708B Section 7.4.8.
+ // Extended Control Code Set 2
+ // C3 Table : No commands as of CEA-708B. A decoder must skip.
+ if (mCommand >= Cea708Data.CODE_C3_SKIP4_RANGE_START
+ && mCommand <= Cea708Data.CODE_C3_SKIP4_RANGE_END) {
+ pos += 4;
+ } else if (mCommand >= Cea708Data.CODE_C3_SKIP5_RANGE_START
+ && mCommand <= Cea708Data.CODE_C3_SKIP5_RANGE_END) {
+ pos += 5;
+ }
+ return pos;
+ }
+
+ private int parseG2(byte[] data, int pos) {
+ // For the details of C3 code group, see CEA-708B Section 7.4.5.
+ // Extended Control Code Set 1(G2 Table)
+ switch (mCommand) {
+ case Cea708Data.CODE_G2_TSP:
+ // TODO : TSP is the Transparent space
+ break;
+ case Cea708Data.CODE_G2_NBTSP:
+ // TODO : NBTSP is Non-Breaking Transparent Space.
+ break;
+ case Cea708Data.CODE_G2_BLK:
+ // TODO : BLK indicates a solid block which fills the entire character block
+ // TODO : with a solid foreground color.
+ break;
+ default:
+ break;
+ }
+ return pos;
+ }
+
+ private int parseG3(byte[] data, int pos) {
+ // For the details of C3 code group, see CEA-708B Section 7.4.6.
+ // Future characters and icons(G3 Table)
+ if (mCommand == Cea708Data.CODE_G3_CC) {
+ // TODO : [CC] icon with square corners
+ }
+
+ // Do nothing
+ return pos;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/data/Cea708Data.java b/tuner/src/com/android/tv/tuner/data/Cea708Data.java
new file mode 100644
index 00000000..73a90181
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/data/Cea708Data.java
@@ -0,0 +1,329 @@
+/*
+ * 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.data;
+
+import android.graphics.Color;
+import android.support.annotation.NonNull;
+import com.android.tv.tuner.cc.Cea708Parser;
+
+/** Collection of CEA-708 structures. */
+public class Cea708Data {
+
+ private Cea708Data() {}
+
+ // According to CEA-708B, the range of valid service number is between 1 and 63.
+ public static final int EMPTY_SERVICE_NUMBER = 0;
+
+ // For the details of the ranges of DTVCC code groups, see CEA-708B Table 6.
+ public static final int CODE_C0_RANGE_START = 0x00;
+ public static final int CODE_C0_RANGE_END = 0x1f;
+ public static final int CODE_C1_RANGE_START = 0x80;
+ public static final int CODE_C1_RANGE_END = 0x9f;
+ public static final int CODE_G0_RANGE_START = 0x20;
+ public static final int CODE_G0_RANGE_END = 0x7f;
+ public static final int CODE_G1_RANGE_START = 0xa0;
+ public static final int CODE_G1_RANGE_END = 0xff;
+ public static final int CODE_C2_RANGE_START = 0x00;
+ public static final int CODE_C2_RANGE_END = 0x1f;
+ public static final int CODE_C3_RANGE_START = 0x80;
+ public static final int CODE_C3_RANGE_END = 0x9f;
+ public static final int CODE_G2_RANGE_START = 0x20;
+ public static final int CODE_G2_RANGE_END = 0x7f;
+ public static final int CODE_G3_RANGE_START = 0xa0;
+ public static final int CODE_G3_RANGE_END = 0xff;
+
+ // The following ranges are defined in CEA-708B Section 7.4.1.
+ public static final int CODE_C0_SKIP2_RANGE_START = 0x18;
+ public static final int CODE_C0_SKIP2_RANGE_END = 0x1f;
+ public static final int CODE_C0_SKIP1_RANGE_START = 0x10;
+ public static final int CODE_C0_SKIP1_RANGE_END = 0x17;
+
+ // The following ranges are defined in CEA-708B Section 7.4.7.
+ public static final int CODE_C2_SKIP0_RANGE_START = 0x00;
+ public static final int CODE_C2_SKIP0_RANGE_END = 0x07;
+ public static final int CODE_C2_SKIP1_RANGE_START = 0x08;
+ public static final int CODE_C2_SKIP1_RANGE_END = 0x0f;
+ public static final int CODE_C2_SKIP2_RANGE_START = 0x10;
+ public static final int CODE_C2_SKIP2_RANGE_END = 0x17;
+ public static final int CODE_C2_SKIP3_RANGE_START = 0x18;
+ public static final int CODE_C2_SKIP3_RANGE_END = 0x1f;
+
+ // The following ranges are defined in CEA-708B Section 7.4.8.
+ public static final int CODE_C3_SKIP4_RANGE_START = 0x80;
+ public static final int CODE_C3_SKIP4_RANGE_END = 0x87;
+ public static final int CODE_C3_SKIP5_RANGE_START = 0x88;
+ public static final int CODE_C3_SKIP5_RANGE_END = 0x8f;
+
+ // The following values are the special characters of CEA-708 spec.
+ public static final int CODE_C0_NUL = 0x00;
+ public static final int CODE_C0_ETX = 0x03;
+ public static final int CODE_C0_BS = 0x08;
+ public static final int CODE_C0_FF = 0x0c;
+ public static final int CODE_C0_CR = 0x0d;
+ public static final int CODE_C0_HCR = 0x0e;
+ public static final int CODE_C0_EXT1 = 0x10;
+ public static final int CODE_C0_P16 = 0x18;
+ public static final int CODE_G0_MUSICNOTE = 0x7f;
+ public static final int CODE_G2_TSP = 0x20;
+ public static final int CODE_G2_NBTSP = 0x21;
+ public static final int CODE_G2_BLK = 0x30;
+ public static final int CODE_G3_CC = 0xa0;
+
+ // The following values are the command bits of CEA-708 spec.
+ public static final int CODE_C1_CW0 = 0x80;
+ public static final int CODE_C1_CW1 = 0x81;
+ public static final int CODE_C1_CW2 = 0x82;
+ public static final int CODE_C1_CW3 = 0x83;
+ public static final int CODE_C1_CW4 = 0x84;
+ public static final int CODE_C1_CW5 = 0x85;
+ public static final int CODE_C1_CW6 = 0x86;
+ public static final int CODE_C1_CW7 = 0x87;
+ public static final int CODE_C1_CLW = 0x88;
+ public static final int CODE_C1_DSW = 0x89;
+ public static final int CODE_C1_HDW = 0x8a;
+ public static final int CODE_C1_TGW = 0x8b;
+ public static final int CODE_C1_DLW = 0x8c;
+ public static final int CODE_C1_DLY = 0x8d;
+ public static final int CODE_C1_DLC = 0x8e;
+ public static final int CODE_C1_RST = 0x8f;
+ public static final int CODE_C1_SPA = 0x90;
+ public static final int CODE_C1_SPC = 0x91;
+ public static final int CODE_C1_SPL = 0x92;
+ public static final int CODE_C1_SWA = 0x97;
+ public static final int CODE_C1_DF0 = 0x98;
+ public static final int CODE_C1_DF1 = 0x99;
+ public static final int CODE_C1_DF2 = 0x9a;
+ public static final int CODE_C1_DF3 = 0x9b;
+ public static final int CODE_C1_DF4 = 0x9c;
+ public static final int CODE_C1_DF5 = 0x9d;
+ public static final int CODE_C1_DF6 = 0x9e;
+ public static final int CODE_C1_DF7 = 0x9f;
+
+ public static class CcPacket implements Comparable<CcPacket> {
+ public final byte[] bytes;
+ public final int ccCount;
+ public final long pts;
+
+ public CcPacket(byte[] bytes, int ccCount, long pts) {
+ this.bytes = bytes;
+ this.ccCount = ccCount;
+ this.pts = pts;
+ }
+
+ @Override
+ public int compareTo(@NonNull CcPacket another) {
+ return Long.compare(pts, another.pts);
+ }
+ }
+
+ /** CEA-708B-specific color. */
+ public static class CaptionColor {
+ public static final int OPACITY_SOLID = 0;
+ public static final int OPACITY_FLASH = 1;
+ public static final int OPACITY_TRANSLUCENT = 2;
+ public static final int OPACITY_TRANSPARENT = 3;
+
+ private static final int[] COLOR_MAP = new int[] {0x00, 0x0f, 0xf0, 0xff};
+ private static final int[] OPACITY_MAP = new int[] {0xff, 0xfe, 0x80, 0x00};
+
+ public final int opacity;
+ public final int red;
+ public final int green;
+ public final int blue;
+
+ public CaptionColor(int opacity, int red, int green, int blue) {
+ this.opacity = opacity;
+ this.red = red;
+ this.green = green;
+ this.blue = blue;
+ }
+
+ public int getArgbValue() {
+ return Color.argb(
+ OPACITY_MAP[opacity], COLOR_MAP[red], COLOR_MAP[green], COLOR_MAP[blue]);
+ }
+ }
+
+ /** Caption event generated by {@link Cea708Parser}. */
+ public static class CaptionEvent {
+ @Cea708Parser.CaptionEmitType public final int type;
+ public final Object obj;
+
+ public CaptionEvent(int type, Object obj) {
+ this.type = type;
+ this.obj = obj;
+ }
+ }
+
+ /** Pen style information. */
+ public static class CaptionPenAttr {
+ // Pen sizes
+ public static final int PEN_SIZE_SMALL = 0;
+ public static final int PEN_SIZE_STANDARD = 1;
+ public static final int PEN_SIZE_LARGE = 2;
+
+ // Offsets
+ public static final int OFFSET_SUBSCRIPT = 0;
+ public static final int OFFSET_NORMAL = 1;
+ public static final int OFFSET_SUPERSCRIPT = 2;
+
+ public final int penSize;
+ public final int penOffset;
+ public final int textTag;
+ public final int fontTag;
+ public final int edgeType;
+ public final boolean underline;
+ public final boolean italic;
+
+ public CaptionPenAttr(
+ int penSize,
+ int penOffset,
+ int textTag,
+ int fontTag,
+ int edgeType,
+ boolean underline,
+ boolean italic) {
+ this.penSize = penSize;
+ this.penOffset = penOffset;
+ this.textTag = textTag;
+ this.fontTag = fontTag;
+ this.edgeType = edgeType;
+ this.underline = underline;
+ this.italic = italic;
+ }
+ }
+
+ /**
+ * {@link CaptionColor} objects that indicate the foreground, background, and edge color of a
+ * pen.
+ */
+ public static class CaptionPenColor {
+ public final CaptionColor foregroundColor;
+ public final CaptionColor backgroundColor;
+ public final CaptionColor edgeColor;
+
+ public CaptionPenColor(
+ CaptionColor foregroundColor,
+ CaptionColor backgroundColor,
+ CaptionColor edgeColor) {
+ this.foregroundColor = foregroundColor;
+ this.backgroundColor = backgroundColor;
+ this.edgeColor = edgeColor;
+ }
+ }
+
+ /** Location information of a pen. */
+ public static class CaptionPenLocation {
+ public final int row;
+ public final int column;
+
+ public CaptionPenLocation(int row, int column) {
+ this.row = row;
+ this.column = column;
+ }
+ }
+
+ /** Attributes of a caption window, which is defined in CEA-708B. */
+ public static class CaptionWindowAttr {
+ public static final int JUSTIFY_LEFT = 0;
+ public static final int JUSTIFY_CENTER = 2;
+ public static final int PRINT_LEFT_TO_RIGHT = 0;
+ public static final int PRINT_RIGHT_TO_LEFT = 1;
+ public static final int PRINT_TOP_TO_BOTTOM = 2;
+ public static final int PRINT_BOTTOM_TO_TOP = 3;
+
+ public final CaptionColor fillColor;
+ public final CaptionColor borderColor;
+ public final int borderType;
+ public final boolean wordWrap;
+ public final int printDirection;
+ public final int scrollDirection;
+ public final int justify;
+ public final int effectDirection;
+ public final int effectSpeed;
+ public final int displayEffect;
+
+ public CaptionWindowAttr(
+ CaptionColor fillColor,
+ CaptionColor borderColor,
+ int borderType,
+ boolean wordWrap,
+ int printDirection,
+ int scrollDirection,
+ int justify,
+ int effectDirection,
+ int effectSpeed,
+ int displayEffect) {
+ this.fillColor = fillColor;
+ this.borderColor = borderColor;
+ this.borderType = borderType;
+ this.wordWrap = wordWrap;
+ this.printDirection = printDirection;
+ this.scrollDirection = scrollDirection;
+ this.justify = justify;
+ this.effectDirection = effectDirection;
+ this.effectSpeed = effectSpeed;
+ this.displayEffect = displayEffect;
+ }
+ }
+
+ /** Construction information of the caption window of CEA-708B. */
+ public static class CaptionWindow {
+ public final int id;
+ public final boolean visible;
+ public final boolean rowLock;
+ public final boolean columnLock;
+ public final int priority;
+ public final boolean relativePositioning;
+ public final int anchorVertical;
+ public final int anchorHorizontal;
+ public final int anchorId;
+ public final int rowCount;
+ public final int columnCount;
+ public final int penStyle;
+ public final int windowStyle;
+
+ public CaptionWindow(
+ int id,
+ boolean visible,
+ boolean rowLock,
+ boolean columnLock,
+ int priority,
+ boolean relativePositioning,
+ int anchorVertical,
+ int anchorHorizontal,
+ int anchorId,
+ int rowCount,
+ int columnCount,
+ int penStyle,
+ int windowStyle) {
+ this.id = id;
+ this.visible = visible;
+ this.rowLock = rowLock;
+ this.columnLock = columnLock;
+ this.priority = priority;
+ this.relativePositioning = relativePositioning;
+ this.anchorVertical = anchorVertical;
+ this.anchorHorizontal = anchorHorizontal;
+ this.anchorId = anchorId;
+ this.rowCount = rowCount;
+ this.columnCount = columnCount;
+ this.penStyle = penStyle;
+ this.windowStyle = windowStyle;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/data/PsiData.java b/tuner/src/com/android/tv/tuner/data/PsiData.java
new file mode 100644
index 00000000..9b7c2e2c
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/data/PsiData.java
@@ -0,0 +1,93 @@
+/*
+ * 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.data;
+
+import com.android.tv.tuner.data.nano.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import java.util.List;
+
+/** Collection of MPEG PSI table items. */
+public class PsiData {
+
+ private PsiData() {}
+
+ public static class PatItem {
+ private final int mProgramNo;
+ private final int mPmtPid;
+
+ public PatItem(int programNo, int pmtPid) {
+ mProgramNo = programNo;
+ mPmtPid = pmtPid;
+ }
+
+ public int getProgramNo() {
+ return mProgramNo;
+ }
+
+ public int getPmtPid() {
+ return mPmtPid;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Program No: %x PMT Pid: %x", mProgramNo, mPmtPid);
+ }
+ }
+
+ public static class PmtItem {
+ public static final int ES_PID_PCR = 0x100;
+
+ private final int mStreamType;
+ private final int mEsPid;
+ private final List<AtscAudioTrack> mAudioTracks;
+ private final List<AtscCaptionTrack> mCaptionTracks;
+
+ public PmtItem(
+ int streamType,
+ int esPid,
+ List<AtscAudioTrack> audioTracks,
+ List<AtscCaptionTrack> captionTracks) {
+ mStreamType = streamType;
+ mEsPid = esPid;
+ mAudioTracks = audioTracks;
+ mCaptionTracks = captionTracks;
+ }
+
+ public int getStreamType() {
+ return mStreamType;
+ }
+
+ public int getEsPid() {
+ return mEsPid;
+ }
+
+ public List<AtscAudioTrack> getAudioTracks() {
+ return mAudioTracks;
+ }
+
+ public List<AtscCaptionTrack> getCaptionTracks() {
+ return mCaptionTracks;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "Stream Type: %x ES Pid: %x AudioTracks: %s CaptionTracks: %s",
+ mStreamType, mEsPid, mAudioTracks, mCaptionTracks);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/data/PsipData.java b/tuner/src/com/android/tv/tuner/data/PsipData.java
new file mode 100644
index 00000000..239009dc
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/data/PsipData.java
@@ -0,0 +1,871 @@
+/*
+ * 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.data;
+
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import com.android.tv.common.util.StringUtils;
+import com.android.tv.tuner.data.nano.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import com.android.tv.tuner.ts.SectionParser;
+import com.android.tv.tuner.util.ConvertUtils;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+
+/** Collection of ATSC PSIP table items. */
+public class PsipData {
+
+ private PsipData() {}
+
+ public static class PsipSection {
+ private final int mTableId;
+ private final int mTableIdExtension;
+ private final int mSectionNumber;
+ private final boolean mCurrentNextIndicator;
+
+ public static PsipSection create(byte[] data) {
+ if (data.length < 9) {
+ return null;
+ }
+ int tableId = data[0] & 0xff;
+ int tableIdExtension = (data[3] & 0xff) << 8 | (data[4] & 0xff);
+ int sectionNumber = data[6] & 0xff;
+ boolean currentNextIndicator = (data[5] & 0x01) != 0;
+ return new PsipSection(tableId, tableIdExtension, sectionNumber, currentNextIndicator);
+ }
+
+ private PsipSection(
+ int tableId,
+ int tableIdExtension,
+ int sectionNumber,
+ boolean currentNextIndicator) {
+ mTableId = tableId;
+ mTableIdExtension = tableIdExtension;
+ mSectionNumber = sectionNumber;
+ mCurrentNextIndicator = currentNextIndicator;
+ }
+
+ public int getTableId() {
+ return mTableId;
+ }
+
+ public int getTableIdExtension() {
+ return mTableIdExtension;
+ }
+
+ public int getSectionNumber() {
+ return mSectionNumber;
+ }
+
+ // This is for indicating that the section sent is applicable.
+ // We only consider a situation where currentNextIndicator is expected to have a true value.
+ // So, we are not going to compare this variable in hashCode() and equals() methods.
+ public boolean getCurrentNextIndicator() {
+ return mCurrentNextIndicator;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = 17;
+ result = 31 * result + mTableId;
+ result = 31 * result + mTableIdExtension;
+ result = 31 * result + mSectionNumber;
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof PsipSection) {
+ PsipSection another = (PsipSection) obj;
+ return mTableId == another.getTableId()
+ && mTableIdExtension == another.getTableIdExtension()
+ && mSectionNumber == another.getSectionNumber();
+ }
+ return false;
+ }
+ }
+
+ /** {@link TvTracksInterface} for serving the audio and caption tracks. */
+ public interface TvTracksInterface {
+ /** Set the flag that tells the caption tracks have been found in this section container. */
+ void setHasCaptionTrack();
+
+ /**
+ * Returns whether or not the caption tracks have been found in this section container. If
+ * true, zero caption track will be interpreted as a clearance of the caption tracks.
+ */
+ boolean hasCaptionTrack();
+
+ /** Returns the audio tracks received. */
+ List<AtscAudioTrack> getAudioTracks();
+
+ /** Returns the caption tracks received. */
+ List<AtscCaptionTrack> getCaptionTracks();
+ }
+
+ public static class MgtItem {
+ public static final int TABLE_TYPE_EIT_RANGE_START = 0x0100;
+ public static final int TABLE_TYPE_EIT_RANGE_END = 0x017f;
+ public static final int TABLE_TYPE_CHANNEL_ETT = 0x0004;
+ public static final int TABLE_TYPE_ETT_RANGE_START = 0x0200;
+ public static final int TABLE_TYPE_ETT_RANGE_END = 0x027f;
+
+ private final int mTableType;
+ private final int mTableTypePid;
+
+ public MgtItem(int tableType, int tableTypePid) {
+ mTableType = tableType;
+ mTableTypePid = tableTypePid;
+ }
+
+ public int getTableType() {
+ return mTableType;
+ }
+
+ public int getTableTypePid() {
+ return mTableTypePid;
+ }
+ }
+
+ public static class VctItem {
+ private final String mShortName;
+ private final String mLongName;
+ private final int mServiceType;
+ private final int mChannelTsid;
+ private final int mProgramNumber;
+ private final int mMajorChannelNumber;
+ private final int mMinorChannelNumber;
+ private final int mSourceId;
+ private String mDescription;
+
+ public VctItem(
+ String shortName,
+ String longName,
+ int serviceType,
+ int channelTsid,
+ int programNumber,
+ int majorChannelNumber,
+ int minorChannelNumber,
+ int sourceId) {
+ mShortName = shortName;
+ mLongName = longName;
+ mServiceType = serviceType;
+ mChannelTsid = channelTsid;
+ mProgramNumber = programNumber;
+ mMajorChannelNumber = majorChannelNumber;
+ mMinorChannelNumber = minorChannelNumber;
+ mSourceId = sourceId;
+ }
+
+ public String getShortName() {
+ return mShortName;
+ }
+
+ public String getLongName() {
+ return mLongName;
+ }
+
+ public int getServiceType() {
+ return mServiceType;
+ }
+
+ public int getChannelTsid() {
+ return mChannelTsid;
+ }
+
+ public int getProgramNumber() {
+ return mProgramNumber;
+ }
+
+ public int getMajorChannelNumber() {
+ return mMajorChannelNumber;
+ }
+
+ public int getMinorChannelNumber() {
+ return mMinorChannelNumber;
+ }
+
+ public int getSourceId() {
+ return mSourceId;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ Locale.US,
+ "ShortName: %s LongName: %s ServiceType: %d ChannelTsid: %x "
+ + "ProgramNumber:%d %d-%d SourceId: %x",
+ mShortName,
+ mLongName,
+ mServiceType,
+ mChannelTsid,
+ mProgramNumber,
+ mMajorChannelNumber,
+ mMinorChannelNumber,
+ mSourceId);
+ }
+
+ public void setDescription(String description) {
+ mDescription = description;
+ }
+
+ public String getDescription() {
+ return mDescription;
+ }
+ }
+
+ public static class SdtItem {
+ private final String mServiceName;
+ private final String mServiceProviderName;
+ private final int mServiceType;
+ private final int mServiceId;
+ private final int mOriginalNetWorkId;
+
+ public SdtItem(
+ String serviceName,
+ String serviceProviderName,
+ int serviceType,
+ int serviceId,
+ int originalNetWorkId) {
+ mServiceName = serviceName;
+ mServiceProviderName = serviceProviderName;
+ mServiceType = serviceType;
+ mServiceId = serviceId;
+ mOriginalNetWorkId = originalNetWorkId;
+ }
+
+ public String getServiceName() {
+ return mServiceName;
+ }
+
+ public String getServiceProviderName() {
+ return mServiceProviderName;
+ }
+
+ public int getServiceType() {
+ return mServiceType;
+ }
+
+ public int getServiceId() {
+ return mServiceId;
+ }
+
+ public int getOriginalNetworkId() {
+ return mOriginalNetWorkId;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "ServiceName: %s ServiceProviderName:%s ServiceType:%d "
+ + "OriginalNetworkId:%d",
+ mServiceName, mServiceProviderName, mServiceType, mOriginalNetWorkId);
+ }
+ }
+
+ /** A base class for descriptors of Ts packets. */
+ public abstract static class TsDescriptor {
+ public abstract int getTag();
+ }
+
+ public static class ContentAdvisoryDescriptor extends TsDescriptor {
+ private final List<RatingRegion> mRatingRegions;
+
+ public ContentAdvisoryDescriptor(List<RatingRegion> ratingRegions) {
+ mRatingRegions = ratingRegions;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_CONTENT_ADVISORY;
+ }
+
+ public List<RatingRegion> getRatingRegions() {
+ return mRatingRegions;
+ }
+ }
+
+ public static class CaptionServiceDescriptor extends TsDescriptor {
+ private final List<AtscCaptionTrack> mCaptionTracks;
+
+ public CaptionServiceDescriptor(List<AtscCaptionTrack> captionTracks) {
+ mCaptionTracks = captionTracks;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_CAPTION_SERVICE;
+ }
+
+ public List<AtscCaptionTrack> getCaptionTracks() {
+ return mCaptionTracks;
+ }
+ }
+
+ public static class ExtendedChannelNameDescriptor extends TsDescriptor {
+ private final String mLongChannelName;
+
+ public ExtendedChannelNameDescriptor(String longChannelName) {
+ mLongChannelName = longChannelName;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME;
+ }
+
+ public String getLongChannelName() {
+ return mLongChannelName;
+ }
+ }
+
+ public static class GenreDescriptor extends TsDescriptor {
+ private final String[] mBroadcastGenres;
+ private final String[] mCanonicalGenres;
+
+ public GenreDescriptor(String[] broadcastGenres, String[] canonicalGenres) {
+ mBroadcastGenres = broadcastGenres;
+ mCanonicalGenres = canonicalGenres;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_GENRE;
+ }
+
+ public String[] getBroadcastGenres() {
+ return mBroadcastGenres;
+ }
+
+ public String[] getCanonicalGenres() {
+ return mCanonicalGenres;
+ }
+ }
+
+ public static class Ac3AudioDescriptor extends TsDescriptor {
+ // See A/52 Annex A. Table A4.2
+ private static final byte SAMPLE_RATE_CODE_48000HZ = 0;
+ private static final byte SAMPLE_RATE_CODE_44100HZ = 1;
+ private static final byte SAMPLE_RATE_CODE_32000HZ = 2;
+
+ private final byte mSampleRateCode;
+ private final byte mBsid;
+ private final byte mBitRateCode;
+ private final byte mSurroundMode;
+ private final byte mBsmod;
+ private final int mNumChannels;
+ private final boolean mFullSvc;
+ private final byte mLangCod;
+ private final byte mLangCod2;
+ private final byte mMainId;
+ private final byte mPriority;
+ private final byte mAsvcflags;
+ private final String mText;
+ private final String mLanguage;
+ private final String mLanguage2;
+
+ public Ac3AudioDescriptor(
+ byte sampleRateCode,
+ byte bsid,
+ byte bitRateCode,
+ byte surroundMode,
+ byte bsmod,
+ int numChannels,
+ boolean fullSvc,
+ byte langCod,
+ byte langCod2,
+ byte mainId,
+ byte priority,
+ byte asvcflags,
+ String text,
+ String language,
+ String language2) {
+ mSampleRateCode = sampleRateCode;
+ mBsid = bsid;
+ mBitRateCode = bitRateCode;
+ mSurroundMode = surroundMode;
+ mBsmod = bsmod;
+ mNumChannels = numChannels;
+ mFullSvc = fullSvc;
+ mLangCod = langCod;
+ mLangCod2 = langCod2;
+ mMainId = mainId;
+ mPriority = priority;
+ mAsvcflags = asvcflags;
+ mText = text;
+ mLanguage = language;
+ mLanguage2 = language2;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_AC3_AUDIO_STREAM;
+ }
+
+ public byte getSampleRateCode() {
+ return mSampleRateCode;
+ }
+
+ public int getSampleRate() {
+ switch (mSampleRateCode) {
+ case SAMPLE_RATE_CODE_48000HZ:
+ return 48000;
+ case SAMPLE_RATE_CODE_44100HZ:
+ return 44100;
+ case SAMPLE_RATE_CODE_32000HZ:
+ return 32000;
+ default:
+ return 0;
+ }
+ }
+
+ public byte getBsid() {
+ return mBsid;
+ }
+
+ public byte getBitRateCode() {
+ return mBitRateCode;
+ }
+
+ public byte getSurroundMode() {
+ return mSurroundMode;
+ }
+
+ public byte getBsmod() {
+ return mBsmod;
+ }
+
+ public int getNumChannels() {
+ return mNumChannels;
+ }
+
+ public boolean isFullSvc() {
+ return mFullSvc;
+ }
+
+ public byte getLangCod() {
+ return mLangCod;
+ }
+
+ public byte getLangCod2() {
+ return mLangCod2;
+ }
+
+ public byte getMainId() {
+ return mMainId;
+ }
+
+ public byte getPriority() {
+ return mPriority;
+ }
+
+ public byte getAsvcflags() {
+ return mAsvcflags;
+ }
+
+ public String getText() {
+ return mText;
+ }
+
+ public String getLanguage() {
+ return mLanguage;
+ }
+
+ public String getLanguage2() {
+ return mLanguage2;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ Locale.US,
+ "AC3 audio stream sampleRateCode: %d, bsid: %d, bitRateCode: %d, "
+ + "surroundMode: %d, bsmod: %d, numChannels: %d, fullSvc: %s, langCod: %d, "
+ + "langCod2: %d, mainId: %d, priority: %d, avcflags: %d, text: %s, language: %s"
+ + ", language2: %s",
+ mSampleRateCode,
+ mBsid,
+ mBitRateCode,
+ mSurroundMode,
+ mBsmod,
+ mNumChannels,
+ mFullSvc,
+ mLangCod,
+ mLangCod2,
+ mMainId,
+ mPriority,
+ mAsvcflags,
+ mText,
+ mLanguage,
+ mLanguage2);
+ }
+ }
+
+ public static class Iso639LanguageDescriptor extends TsDescriptor {
+ private final List<AtscAudioTrack> mAudioTracks;
+
+ public Iso639LanguageDescriptor(List<AtscAudioTrack> audioTracks) {
+ mAudioTracks = audioTracks;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_ISO639LANGUAGE;
+ }
+
+ public List<AtscAudioTrack> getAudioTracks() {
+ return mAudioTracks;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%s %s", getClass().getName(), mAudioTracks);
+ }
+ }
+
+ public static class ServiceDescriptor extends TsDescriptor {
+ private final int mServiceType;
+ private final String mServiceProviderName;
+ private final String mServiceName;
+
+ public ServiceDescriptor(int serviceType, String serviceProviderName, String serviceName) {
+ mServiceType = serviceType;
+ mServiceProviderName = serviceProviderName;
+ mServiceName = serviceName;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DVB_DESCRIPTOR_TAG_SERVICE;
+ }
+
+ public int getServiceType() {
+ return mServiceType;
+ }
+
+ public String getServiceProviderName() {
+ return mServiceProviderName;
+ }
+
+ public String getServiceName() {
+ return mServiceName;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "Service descriptor, service type: %d, "
+ + "service provider name: %s, "
+ + "service name: %s",
+ mServiceType, mServiceProviderName, mServiceName);
+ }
+ }
+
+ public static class ShortEventDescriptor extends TsDescriptor {
+ private final String mLanguage;
+ private final String mEventName;
+ private final String mText;
+
+ public ShortEventDescriptor(String language, String eventName, String text) {
+ mLanguage = language;
+ mEventName = eventName;
+ mText = text;
+ }
+
+ public String getEventName() {
+ return mEventName;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DVB_DESCRIPTOR_TAG_SHORT_EVENT;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "ShortEvent Descriptor, language:%s, event name: %s, " + "text:%s",
+ mLanguage, mEventName, mText);
+ }
+ }
+
+ public static class ParentalRatingDescriptor extends TsDescriptor {
+ private final HashMap<String, Integer> mRatings;
+
+ public ParentalRatingDescriptor(HashMap<String, Integer> ratings) {
+ mRatings = ratings;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DVB_DESCRIPTOR_TAG_PARENTAL_RATING;
+ }
+
+ public HashMap<String, Integer> getRatings() {
+ return mRatings;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Parental rating descriptor, ratings:" + mRatings);
+ }
+ }
+
+ public static class RatingRegion {
+ private final int mName;
+ private final String mDescription;
+ private final List<RegionalRating> mRegionalRatings;
+
+ public RatingRegion(int name, String description, List<RegionalRating> regionalRatings) {
+ mName = name;
+ mDescription = description;
+ mRegionalRatings = regionalRatings;
+ }
+
+ public int getName() {
+ return mName;
+ }
+
+ public String getDescription() {
+ return mDescription;
+ }
+
+ public List<RegionalRating> getRegionalRatings() {
+ return mRegionalRatings;
+ }
+ }
+
+ public static class RegionalRating {
+ private final int mDimension;
+ private final int mRating;
+
+ public RegionalRating(int dimension, int rating) {
+ mDimension = dimension;
+ mRating = rating;
+ }
+
+ public int getDimension() {
+ return mDimension;
+ }
+
+ public int getRating() {
+ return mRating;
+ }
+ }
+
+ public static class EitItem implements Comparable<EitItem>, TvTracksInterface {
+ public static final long INVALID_PROGRAM_ID = -1;
+
+ // A program id is a primary key of TvContract.Programs table. So it must be positive.
+ private final long mProgramId;
+ private final int mEventId;
+ private final String mTitleText;
+ private String mDescription;
+ private final long mStartTime;
+ private final int mLengthInSecond;
+ private final String mContentRating;
+ private final List<AtscAudioTrack> mAudioTracks;
+ private final List<AtscCaptionTrack> mCaptionTracks;
+ private boolean mHasCaptionTrack;
+ private final String mBroadcastGenre;
+ private final String mCanonicalGenre;
+
+ public EitItem(
+ long programId,
+ int eventId,
+ String titleText,
+ long startTime,
+ int lengthInSecond,
+ String contentRating,
+ List<AtscAudioTrack> audioTracks,
+ List<AtscCaptionTrack> captionTracks,
+ String broadcastGenre,
+ String canonicalGenre,
+ String description) {
+ mProgramId = programId;
+ mEventId = eventId;
+ mTitleText = titleText;
+ mStartTime = startTime;
+ mLengthInSecond = lengthInSecond;
+ mContentRating = contentRating;
+ mAudioTracks = audioTracks;
+ mCaptionTracks = captionTracks;
+ mBroadcastGenre = broadcastGenre;
+ mCanonicalGenre = canonicalGenre;
+ mDescription = description;
+ }
+
+ public long getProgramId() {
+ return mProgramId;
+ }
+
+ public int getEventId() {
+ return mEventId;
+ }
+
+ public String getTitleText() {
+ return mTitleText;
+ }
+
+ public void setDescription(String description) {
+ mDescription = description;
+ }
+
+ public String getDescription() {
+ return mDescription;
+ }
+
+ public long getStartTime() {
+ return mStartTime;
+ }
+
+ public int getLengthInSecond() {
+ return mLengthInSecond;
+ }
+
+ public long getStartTimeUtcMillis() {
+ return ConvertUtils.convertGPSTimeToUnixEpoch(mStartTime) * DateUtils.SECOND_IN_MILLIS;
+ }
+
+ public long getEndTimeUtcMillis() {
+ return ConvertUtils.convertGPSTimeToUnixEpoch(mStartTime + mLengthInSecond)
+ * DateUtils.SECOND_IN_MILLIS;
+ }
+
+ public String getContentRating() {
+ return mContentRating;
+ }
+
+ @Override
+ public List<AtscAudioTrack> getAudioTracks() {
+ return mAudioTracks;
+ }
+
+ @Override
+ public List<AtscCaptionTrack> getCaptionTracks() {
+ return mCaptionTracks;
+ }
+
+ public String getBroadcastGenre() {
+ return mBroadcastGenre;
+ }
+
+ public String getCanonicalGenre() {
+ return mCanonicalGenre;
+ }
+
+ @Override
+ public void setHasCaptionTrack() {
+ mHasCaptionTrack = true;
+ }
+
+ @Override
+ public boolean hasCaptionTrack() {
+ return mHasCaptionTrack;
+ }
+
+ @Override
+ public int compareTo(@NonNull EitItem item) {
+ // The list of caption tracks and the program ids are not compared in here because the
+ // channels in TIF have the concept of the caption and audio tracks while the programs
+ // do not and the programs in TIF only have a program id since they are the rows of
+ // Content Provider.
+ int ret = mEventId - item.getEventId();
+ if (ret != 0) {
+ return ret;
+ }
+ ret = StringUtils.compare(mTitleText, item.getTitleText());
+ if (ret != 0) {
+ return ret;
+ }
+ if (mStartTime > item.getStartTime()) {
+ return 1;
+ } else if (mStartTime < item.getStartTime()) {
+ return -1;
+ }
+ if (mLengthInSecond > item.getLengthInSecond()) {
+ return 1;
+ } else if (mLengthInSecond < item.getLengthInSecond()) {
+ return -1;
+ }
+
+ // Compares content ratings
+ ret = StringUtils.compare(mContentRating, item.getContentRating());
+ if (ret != 0) {
+ return ret;
+ }
+
+ // Compares broadcast genres
+ ret = StringUtils.compare(mBroadcastGenre, item.getBroadcastGenre());
+ if (ret != 0) {
+ return ret;
+ }
+ // Compares canonical genres
+ ret = StringUtils.compare(mCanonicalGenre, item.getCanonicalGenre());
+ if (ret != 0) {
+ return ret;
+ }
+
+ // Compares descriptions
+ return StringUtils.compare(mDescription, item.getDescription());
+ }
+
+ public String getAudioLanguage() {
+ if (mAudioTracks == null) {
+ return "";
+ }
+ ArrayList<String> languages = new ArrayList<>();
+ for (AtscAudioTrack audioTrack : mAudioTracks) {
+ languages.add(audioTrack.language);
+ }
+ return TextUtils.join(",", languages);
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ Locale.US,
+ "EitItem programId: %d, eventId: %d, title: %s, startTime: %10d, "
+ + "length: %6d, rating: %s, audio tracks: %d, caption tracks: %d, "
+ + "genres (broadcast: %s, canonical: %s), description: %s",
+ mProgramId,
+ mEventId,
+ mTitleText,
+ mStartTime,
+ mLengthInSecond,
+ mContentRating,
+ mAudioTracks != null ? mAudioTracks.size() : 0,
+ mCaptionTracks != null ? mCaptionTracks.size() : 0,
+ mBroadcastGenre,
+ mCanonicalGenre,
+ mDescription);
+ }
+ }
+
+ public static class EttItem {
+ public final int eventId;
+ public final String text;
+
+ public EttItem(int eventId, String text) {
+ this.eventId = eventId;
+ this.text = text;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/data/TunerChannel.java b/tuner/src/com/android/tv/tuner/data/TunerChannel.java
new file mode 100644
index 00000000..d20c343b
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/data/TunerChannel.java
@@ -0,0 +1,552 @@
+/*
+ * 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.data;
+
+import android.database.Cursor;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import com.android.tv.common.util.StringUtils;
+import com.android.tv.tuner.data.nano.Channel;
+import com.android.tv.tuner.data.nano.Channel.TunerChannelProto;
+import com.android.tv.tuner.data.nano.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import com.android.tv.tuner.util.Ints;
+import com.google.protobuf.nano.MessageNano;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/** A class that represents a single channel accessible through a tuner. */
+public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracksInterface {
+ private static final String TAG = "TunerChannel";
+
+ /** Channel number separator between major number and minor number. */
+ public static final char CHANNEL_NUMBER_SEPARATOR = '-';
+
+ // See ATSC Code Points Registry.
+ private static final String[] ATSC_SERVICE_TYPE_NAMES =
+ new String[] {
+ "ATSC Reserved",
+ "Analog television channels",
+ "ATSC_digital_television",
+ "ATSC_audio",
+ "ATSC_data_only_service",
+ "Software Download",
+ "Unassociated/Small Screen Service",
+ "Parameterized Service",
+ "ATSC NRT Service",
+ "Extended Parameterized Service"
+ };
+ private static final String ATSC_SERVICE_TYPE_NAME_RESERVED =
+ ATSC_SERVICE_TYPE_NAMES[Channel.AtscServiceType.SERVICE_TYPE_ATSC_RESERVED];
+
+ public static final int INVALID_FREQUENCY = -1;
+
+ // According to RFC4259, The number of available PIDs ranges from 0 to 8191.
+ public static final int INVALID_PID = -1;
+
+ // According to ISO13818-1, Mpeg2 StreamType has a range from 0x00 to 0xff.
+ public static final int INVALID_STREAMTYPE = -1;
+
+ // @GuardedBy(this) Writing operations and toByteArray will be guarded. b/34197766
+ private final TunerChannelProto mProto;
+
+ private TunerChannel(
+ PsipData.VctItem channel, int programNumber, List<PsiData.PmtItem> pmtItems, int type) {
+ mProto = new TunerChannelProto();
+ if (channel == null) {
+ mProto.shortName = "";
+ mProto.tsid = 0;
+ mProto.programNumber = programNumber;
+ mProto.virtualMajor = 0;
+ mProto.virtualMinor = 0;
+ } else {
+ mProto.shortName = channel.getShortName();
+ if (channel.getLongName() != null) {
+ mProto.longName = channel.getLongName();
+ }
+ mProto.tsid = channel.getChannelTsid();
+ mProto.programNumber = channel.getProgramNumber();
+ mProto.virtualMajor = channel.getMajorChannelNumber();
+ mProto.virtualMinor = channel.getMinorChannelNumber();
+ if (channel.getDescription() != null) {
+ mProto.description = channel.getDescription();
+ }
+ mProto.serviceType = channel.getServiceType();
+ }
+ initProto(pmtItems, type);
+ }
+
+ private void initProto(List<PsiData.PmtItem> pmtItems, int type) {
+ mProto.type = type;
+ mProto.channelId = -1L;
+ mProto.frequency = INVALID_FREQUENCY;
+ mProto.videoPid = INVALID_PID;
+ mProto.videoStreamType = INVALID_STREAMTYPE;
+ List<Integer> audioPids = new ArrayList<>();
+ List<Integer> audioStreamTypes = new ArrayList<>();
+ for (PsiData.PmtItem pmt : pmtItems) {
+ switch (pmt.getStreamType()) {
+ // MPEG ES stream video types
+ case Channel.VideoStreamType.MPEG1:
+ case Channel.VideoStreamType.MPEG2:
+ case Channel.VideoStreamType.H263:
+ case Channel.VideoStreamType.H264:
+ case Channel.VideoStreamType.H265:
+ mProto.videoPid = pmt.getEsPid();
+ mProto.videoStreamType = pmt.getStreamType();
+ break;
+
+ // MPEG ES stream audio types
+ case Channel.AudioStreamType.MPEG1AUDIO:
+ case Channel.AudioStreamType.MPEG2AUDIO:
+ case Channel.AudioStreamType.MPEG2AACAUDIO:
+ case Channel.AudioStreamType.MPEG4LATMAACAUDIO:
+ case Channel.AudioStreamType.A52AC3AUDIO:
+ case Channel.AudioStreamType.EAC3AUDIO:
+ audioPids.add(pmt.getEsPid());
+ audioStreamTypes.add(pmt.getStreamType());
+ break;
+
+ // Non MPEG ES stream types
+ case 0x100: // PmtItem.ES_PID_PCR:
+ mProto.pcrPid = pmt.getEsPid();
+ break;
+ default:
+ // fall out
+ }
+ }
+ mProto.audioPids = Ints.toArray(audioPids);
+ mProto.audioStreamTypes = Ints.toArray(audioStreamTypes);
+ mProto.audioTrackIndex = (audioPids.size() > 0) ? 0 : -1;
+ }
+
+ private TunerChannel(
+ int programNumber, int type, PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) {
+ mProto = new TunerChannelProto();
+ mProto.tsid = 0;
+ mProto.virtualMajor = 0;
+ mProto.virtualMinor = 0;
+ if (channel == null) {
+ mProto.shortName = "";
+ mProto.programNumber = programNumber;
+ } else {
+ mProto.shortName = channel.getServiceName();
+ mProto.programNumber = channel.getServiceId();
+ mProto.serviceType = channel.getServiceType();
+ }
+ initProto(pmtItems, type);
+ }
+
+ /** Initialize tuner channel with VCT items and PMT items. */
+ public TunerChannel(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) {
+ this(channel, 0, pmtItems, Channel.TunerType.TYPE_TUNER);
+ }
+
+ /** Initialize tuner channel with program number and PMT items. */
+ public TunerChannel(int programNumber, List<PsiData.PmtItem> pmtItems) {
+ this(null, programNumber, pmtItems, Channel.TunerType.TYPE_TUNER);
+ }
+
+ /** Initialize tuner channel with SDT items and PMT items. */
+ public TunerChannel(PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) {
+ this(0, Channel.TunerType.TYPE_TUNER, channel, pmtItems);
+ }
+
+ private TunerChannel(TunerChannelProto tunerChannelProto) {
+ mProto = tunerChannelProto;
+ }
+
+ public static TunerChannel forFile(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) {
+ return new TunerChannel(channel, 0, pmtItems, Channel.TunerType.TYPE_FILE);
+ }
+
+ public static TunerChannel forDvbFile(
+ PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) {
+ return new TunerChannel(0, Channel.TunerType.TYPE_FILE, channel, pmtItems);
+ }
+
+ /**
+ * Create a TunerChannel object suitable for network tuners
+ *
+ * @param major Channel number major
+ * @param minor Channel number minor
+ * @param programNumber Program number
+ * @param shortName Short name
+ * @param recordingProhibited Recording prohibition info
+ * @param videoFormat Video format. Should be {@code null} or one of the followings: {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_240P}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_360P}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_480I}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_480P}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_576I}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_576P}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_720P}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_1080I}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_1080P}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_2160P}, {@link
+ * android.media.tv.TvContract.Channels#VIDEO_FORMAT_4320P}
+ * @return a TunerChannel object
+ */
+ public static TunerChannel forNetwork(
+ int major,
+ int minor,
+ int programNumber,
+ String shortName,
+ boolean recordingProhibited,
+ String videoFormat) {
+ TunerChannel tunerChannel =
+ new TunerChannel(
+ null,
+ programNumber,
+ Collections.EMPTY_LIST,
+ Channel.TunerType.TYPE_NETWORK);
+ tunerChannel.setVirtualMajor(major);
+ tunerChannel.setVirtualMinor(minor);
+ tunerChannel.setShortName(shortName);
+ // Set audio and video pids in order to work around the audio-only channel check.
+ tunerChannel.setAudioPids(new ArrayList<>(Arrays.asList(0)));
+ tunerChannel.selectAudioTrack(0);
+ tunerChannel.setVideoPid(0);
+ tunerChannel.setRecordingProhibited(recordingProhibited);
+ if (videoFormat != null) {
+ tunerChannel.setVideoFormat(videoFormat);
+ }
+ return tunerChannel;
+ }
+
+ public String getName() {
+ return (!mProto.shortName.isEmpty()) ? mProto.shortName : mProto.longName;
+ }
+
+ public String getShortName() {
+ return mProto.shortName;
+ }
+
+ public int getProgramNumber() {
+ return mProto.programNumber;
+ }
+
+ public int getServiceType() {
+ return mProto.serviceType;
+ }
+
+ public String getServiceTypeName() {
+ int serviceType = mProto.serviceType;
+ if (serviceType >= 0 && serviceType < ATSC_SERVICE_TYPE_NAMES.length) {
+ return ATSC_SERVICE_TYPE_NAMES[serviceType];
+ }
+ return ATSC_SERVICE_TYPE_NAME_RESERVED;
+ }
+
+ public int getVirtualMajor() {
+ return mProto.virtualMajor;
+ }
+
+ public int getVirtualMinor() {
+ return mProto.virtualMinor;
+ }
+
+ public int getFrequency() {
+ return mProto.frequency;
+ }
+
+ public String getModulation() {
+ return mProto.modulation;
+ }
+
+ public int getTsid() {
+ return mProto.tsid;
+ }
+
+ public int getVideoPid() {
+ return mProto.videoPid;
+ }
+
+ public synchronized void setVideoPid(int videoPid) {
+ mProto.videoPid = videoPid;
+ }
+
+ public int getVideoStreamType() {
+ return mProto.videoStreamType;
+ }
+
+ public int getAudioPid() {
+ if (mProto.audioTrackIndex == -1) {
+ return INVALID_PID;
+ }
+ return mProto.audioPids[mProto.audioTrackIndex];
+ }
+
+ public int getAudioStreamType() {
+ if (mProto.audioTrackIndex == -1) {
+ return INVALID_STREAMTYPE;
+ }
+ return mProto.audioStreamTypes[mProto.audioTrackIndex];
+ }
+
+ public List<Integer> getAudioPids() {
+ return Ints.asList(mProto.audioPids);
+ }
+
+ public synchronized void setAudioPids(List<Integer> audioPids) {
+ mProto.audioPids = Ints.toArray(audioPids);
+ }
+
+ public List<Integer> getAudioStreamTypes() {
+ return Ints.asList(mProto.audioStreamTypes);
+ }
+
+ public synchronized void setAudioStreamTypes(List<Integer> audioStreamTypes) {
+ mProto.audioStreamTypes = Ints.toArray(audioStreamTypes);
+ }
+
+ public int getPcrPid() {
+ return mProto.pcrPid;
+ }
+
+ public int getType() {
+ return mProto.type;
+ }
+
+ public synchronized void setFilepath(String filepath) {
+ mProto.filepath = filepath == null ? "" : filepath;
+ }
+
+ public String getFilepath() {
+ return mProto.filepath;
+ }
+
+ public synchronized void setVirtualMajor(int virtualMajor) {
+ mProto.virtualMajor = virtualMajor;
+ }
+
+ public synchronized void setVirtualMinor(int virtualMinor) {
+ mProto.virtualMinor = virtualMinor;
+ }
+
+ public synchronized void setShortName(String shortName) {
+ mProto.shortName = shortName == null ? "" : shortName;
+ }
+
+ public synchronized void setFrequency(int frequency) {
+ mProto.frequency = frequency;
+ }
+
+ public synchronized void setModulation(String modulation) {
+ mProto.modulation = modulation == null ? "" : modulation;
+ }
+
+ public boolean hasVideo() {
+ return mProto.videoPid != INVALID_PID;
+ }
+
+ public boolean hasAudio() {
+ return getAudioPid() != INVALID_PID;
+ }
+
+ public long getChannelId() {
+ return mProto.channelId;
+ }
+
+ public synchronized void setChannelId(long channelId) {
+ mProto.channelId = channelId;
+ }
+
+ /**
+ * The flag indicating whether this TV channel is locked or not. This is primarily used for
+ * alternative parental control to prevent unauthorized users from watching the current channel
+ * regardless of the content rating
+ *
+ * @see <a
+ * href="https://developer.android.com/reference/android/media/tv/TvContract.Channels.html#COLUMN_LOCKED">link</a>
+ */
+ public boolean isLocked() {
+ return mProto.locked;
+ }
+
+ public synchronized void setLocked(boolean locked) {
+ mProto.locked = locked;
+ }
+
+ public String getDisplayNumber() {
+ return getDisplayNumber(true);
+ }
+
+ public String getDisplayNumber(boolean ignoreZeroMinorNumber) {
+ if (mProto.virtualMajor != 0 && (mProto.virtualMinor != 0 || !ignoreZeroMinorNumber)) {
+ return String.format(
+ "%d%c%d", mProto.virtualMajor, CHANNEL_NUMBER_SEPARATOR, mProto.virtualMinor);
+ } else if (mProto.virtualMajor != 0) {
+ return Integer.toString(mProto.virtualMajor);
+ } else {
+ return Integer.toString(mProto.programNumber);
+ }
+ }
+
+ public String getDescription() {
+ return mProto.description;
+ }
+
+ @Override
+ public synchronized void setHasCaptionTrack() {
+ mProto.hasCaptionTrack = true;
+ }
+
+ @Override
+ public boolean hasCaptionTrack() {
+ return mProto.hasCaptionTrack;
+ }
+
+ @Override
+ public List<AtscAudioTrack> getAudioTracks() {
+ return Collections.unmodifiableList(Arrays.asList(mProto.audioTracks));
+ }
+
+ public synchronized void setAudioTracks(List<AtscAudioTrack> audioTracks) {
+ mProto.audioTracks = audioTracks.toArray(new AtscAudioTrack[audioTracks.size()]);
+ }
+
+ @Override
+ public List<AtscCaptionTrack> getCaptionTracks() {
+ return Collections.unmodifiableList(Arrays.asList(mProto.captionTracks));
+ }
+
+ public synchronized void setCaptionTracks(List<AtscCaptionTrack> captionTracks) {
+ mProto.captionTracks = captionTracks.toArray(new AtscCaptionTrack[captionTracks.size()]);
+ }
+
+ public synchronized void selectAudioTrack(int index) {
+ if (0 <= index && index < mProto.audioPids.length) {
+ mProto.audioTrackIndex = index;
+ } else {
+ mProto.audioTrackIndex = -1;
+ }
+ }
+
+ public synchronized void setRecordingProhibited(boolean recordingProhibited) {
+ mProto.recordingProhibited = recordingProhibited;
+ }
+
+ public boolean isRecordingProhibited() {
+ return mProto.recordingProhibited;
+ }
+
+ public synchronized void setVideoFormat(String videoFormat) {
+ mProto.videoFormat = videoFormat == null ? "" : videoFormat;
+ }
+
+ public String getVideoFormat() {
+ return mProto.videoFormat;
+ }
+
+ @Override
+ public String toString() {
+ switch (mProto.type) {
+ case Channel.TunerType.TYPE_FILE:
+ return String.format(
+ "{%d-%d %s} Filepath: %s, ProgramNumber %d",
+ mProto.virtualMajor,
+ mProto.virtualMinor,
+ mProto.shortName,
+ mProto.filepath,
+ mProto.programNumber);
+ // case Channel.TunerType.TYPE_TUNER:
+ default:
+ return String.format(
+ "{%d-%d %s} Frequency: %d, ProgramNumber %d",
+ mProto.virtualMajor,
+ mProto.virtualMinor,
+ mProto.shortName,
+ mProto.frequency,
+ mProto.programNumber);
+ }
+ }
+
+ @Override
+ public int compareTo(@NonNull TunerChannel channel) {
+ // In the same frequency, the program number acts as the sub-channel number.
+ int ret = getFrequency() - channel.getFrequency();
+ if (ret != 0) {
+ return ret;
+ }
+ ret = getProgramNumber() - channel.getProgramNumber();
+ if (ret != 0) {
+ return ret;
+ }
+ ret = StringUtils.compare(getName(), channel.getName());
+ if (ret != 0) {
+ return ret;
+ }
+ // For FileTsStreamer, file paths should be compared.
+ return StringUtils.compare(getFilepath(), channel.getFilepath());
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof TunerChannel)) {
+ return false;
+ }
+ return compareTo((TunerChannel) o) == 0;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getFrequency(), getProgramNumber(), getName(), getFilepath());
+ }
+
+ // Serialization
+ public synchronized byte[] toByteArray() {
+ try {
+ return MessageNano.toByteArray(mProto);
+ } catch (Exception e) {
+ // Retry toByteArray. b/34197766
+ Log.w(
+ TAG,
+ "TunerChannel or its variables are modified in multiple thread without lock",
+ e);
+ return MessageNano.toByteArray(mProto);
+ }
+ }
+
+ public static TunerChannel parseFrom(byte[] data) {
+ if (data == null) {
+ return null;
+ }
+ try {
+ return new TunerChannel(TunerChannelProto.parseFrom(data));
+ } catch (IOException e) {
+ Log.e(TAG, "Could not parse from byte array", e);
+ return null;
+ }
+ }
+
+ public static TunerChannel fromCursor(Cursor cursor) {
+ long channelId = cursor.getLong(0);
+ boolean locked = cursor.getInt(1) > 0;
+ byte[] data = cursor.getBlob(2);
+ TunerChannel channel = TunerChannel.parseFrom(data);
+ if (channel != null) {
+ channel.setChannelId(channelId);
+ channel.setLocked(locked);
+ }
+ return channel;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java b/tuner/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java
new file mode 100644
index 00000000..1f48c45b
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java
@@ -0,0 +1,305 @@
+/*
+ * 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.exoplayer;
+
+import android.util.Log;
+import com.android.tv.tuner.cc.Cea708Parser;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaClock;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.util.Assertions;
+import java.io.IOException;
+
+/** A {@link TrackRenderer} for CEA-708 textual subtitles. */
+public class Cea708TextTrackRenderer extends TrackRenderer
+ implements Cea708Parser.OnCea708ParserListener {
+ private static final String TAG = "Cea708TextTrackRenderer";
+ private static final boolean DEBUG = false;
+
+ public static final int MSG_SERVICE_NUMBER = 1;
+ public static final int MSG_ENABLE_CLOSED_CAPTION = 2;
+
+ // According to CEA-708B, the maximum value of closed caption bandwidth is 9600bps.
+ private static final int DEFAULT_INPUT_BUFFER_SIZE = 9600 / 8;
+
+ private final SampleSource.SampleSourceReader mSource;
+ private final SampleHolder mSampleHolder;
+ private final MediaFormatHolder mFormatHolder;
+ private int mServiceNumber;
+ private boolean mInputStreamEnded;
+ private long mCurrentPositionUs;
+ private long mPresentationTimeUs;
+ private int mTrackIndex;
+ private boolean mRenderingDisabled;
+ private Cea708Parser mCea708Parser;
+ private CcListener mCcListener;
+
+ public interface CcListener {
+ void emitEvent(CaptionEvent captionEvent);
+
+ void clearCaption();
+
+ void discoverServiceNumber(int serviceNumber);
+ }
+
+ public Cea708TextTrackRenderer(SampleSource source) {
+ mSource = source.register();
+ mTrackIndex = -1;
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT);
+ mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE);
+ mFormatHolder = new MediaFormatHolder();
+ }
+
+ @Override
+ protected MediaClock getMediaClock() {
+ return null;
+ }
+
+ private boolean handlesMimeType(String mimeType) {
+ return mimeType.equals(MpegTsSampleExtractor.MIMETYPE_TEXT_CEA_708);
+ }
+
+ @Override
+ protected boolean doPrepare(long positionUs) throws ExoPlaybackException {
+ boolean sourcePrepared = mSource.prepare(positionUs);
+ if (!sourcePrepared) {
+ return false;
+ }
+ int trackCount = mSource.getTrackCount();
+ for (int i = 0; i < trackCount; ++i) {
+ MediaFormat trackFormat = mSource.getFormat(i);
+ if (handlesMimeType(trackFormat.mimeType)) {
+ mTrackIndex = i;
+ clearDecodeState();
+ return true;
+ }
+ }
+ // TODO: Check this case. (Source do not have the proper mime type.)
+ return true;
+ }
+
+ @Override
+ protected void onEnabled(int track, long positionUs, boolean joining) {
+ Assertions.checkArgument(mTrackIndex != -1 && track == 0);
+ mSource.enable(mTrackIndex, positionUs);
+ mInputStreamEnded = false;
+ mPresentationTimeUs = positionUs;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+
+ @Override
+ protected void onDisabled() {
+ mSource.disable(mTrackIndex);
+ }
+
+ @Override
+ protected void onReleased() {
+ mSource.release();
+ mCea708Parser = null;
+ }
+
+ @Override
+ protected boolean isEnded() {
+ return mInputStreamEnded;
+ }
+
+ @Override
+ protected boolean isReady() {
+ // Since this track will be fed by {@link VideoTrackRenderer},
+ // it is not required to control transition between ready state and buffering state.
+ return true;
+ }
+
+ @Override
+ protected int getTrackCount() {
+ return mTrackIndex < 0 ? 0 : 1;
+ }
+
+ @Override
+ protected MediaFormat getFormat(int track) {
+ Assertions.checkArgument(mTrackIndex != -1 && track == 0);
+ return mSource.getFormat(mTrackIndex);
+ }
+
+ @Override
+ protected void maybeThrowError() throws ExoPlaybackException {
+ try {
+ mSource.maybeThrowError();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ @Override
+ protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
+ try {
+ mPresentationTimeUs = positionUs;
+ if (!mInputStreamEnded) {
+ processOutput();
+ feedInputBuffer();
+ }
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ private boolean processOutput() {
+ return !mInputStreamEnded
+ && mCea708Parser != null
+ && mCea708Parser.processClosedCaptions(mPresentationTimeUs);
+ }
+
+ private boolean feedInputBuffer() throws IOException, ExoPlaybackException {
+ if (mInputStreamEnded) {
+ return false;
+ }
+ long discontinuity = mSource.readDiscontinuity(mTrackIndex);
+ if (discontinuity != SampleSource.NO_DISCONTINUITY) {
+ if (DEBUG) {
+ Log.d(TAG, "Read discontinuity happened");
+ }
+
+ // TODO: handle input discontinuity for trickplay.
+ clearDecodeState();
+ mPresentationTimeUs = discontinuity;
+ return false;
+ }
+ mSampleHolder.data.clear();
+ mSampleHolder.size = 0;
+ int result =
+ mSource.readData(mTrackIndex, mPresentationTimeUs, mFormatHolder, mSampleHolder);
+ switch (result) {
+ case SampleSource.NOTHING_READ:
+ {
+ return false;
+ }
+ case SampleSource.FORMAT_READ:
+ {
+ if (DEBUG) {
+ Log.i(TAG, "Format was read again");
+ }
+ return true;
+ }
+ case SampleSource.END_OF_STREAM:
+ {
+ if (DEBUG) {
+ Log.i(TAG, "End of stream from SampleSource");
+ }
+ mInputStreamEnded = true;
+ return false;
+ }
+ case SampleSource.SAMPLE_READ:
+ {
+ mSampleHolder.data.flip();
+ if (mCea708Parser != null && !mRenderingDisabled) {
+ mCea708Parser.parseClosedCaption(mSampleHolder.data, mSampleHolder.timeUs);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void clearDecodeState() {
+ mCea708Parser = new Cea708Parser();
+ mCea708Parser.setListener(this);
+ mCea708Parser.setListenServiceNumber(mServiceNumber);
+ }
+
+ @Override
+ protected long getDurationUs() {
+ return mSource.getFormat(mTrackIndex).durationUs;
+ }
+
+ @Override
+ protected long getBufferedPositionUs() {
+ return mSource.getBufferedPositionUs();
+ }
+
+ @Override
+ protected void seekTo(long currentPositionUs) throws ExoPlaybackException {
+ mSource.seekToUs(currentPositionUs);
+ mInputStreamEnded = false;
+ mPresentationTimeUs = currentPositionUs;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+
+ @Override
+ protected void onStarted() {
+ // do nothing.
+ }
+
+ @Override
+ protected void onStopped() {
+ // do nothing.
+ }
+
+ private void setServiceNumber(int serviceNumber) {
+ mServiceNumber = serviceNumber;
+ if (mCea708Parser != null) {
+ mCea708Parser.setListenServiceNumber(serviceNumber);
+ }
+ }
+
+ @Override
+ public void emitEvent(CaptionEvent event) {
+ if (mCcListener != null) {
+ mCcListener.emitEvent(event);
+ }
+ }
+
+ @Override
+ public void discoverServiceNumber(int serviceNumber) {
+ if (mCcListener != null) {
+ mCcListener.discoverServiceNumber(serviceNumber);
+ }
+ }
+
+ public void setCcListener(CcListener ccListener) {
+ mCcListener = ccListener;
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ switch (messageType) {
+ case MSG_SERVICE_NUMBER:
+ setServiceNumber((int) message);
+ break;
+ case MSG_ENABLE_CLOSED_CAPTION:
+ boolean renderingDisabled = (Boolean) message == false;
+ if (mRenderingDisabled != renderingDisabled) {
+ mRenderingDisabled = renderingDisabled;
+ if (mRenderingDisabled) {
+ if (mCea708Parser != null) {
+ mCea708Parser.clear();
+ }
+ if (mCcListener != null) {
+ mCcListener.clearCaption();
+ }
+ }
+ }
+ break;
+ default:
+ super.handleMessage(messageType, message);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java b/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java
new file mode 100644
index 00000000..dc08c072
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2017 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.exoplayer;
+
+import com.google.android.exoplayer2.extractor.Extractor;
+import com.google.android.exoplayer2.extractor.ExtractorsFactory;
+import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory;
+import com.google.android.exoplayer2.extractor.ts.TsExtractor;
+import com.google.android.exoplayer2.util.TimestampAdjuster;
+
+/**
+ * Extractor factory, mainly aim at create TsExtractor with FLAG_ALLOW_NON_IDR_KEYFRAMES flags for
+ * H.264 stream
+ */
+public final class ExoPlayerExtractorsFactory implements ExtractorsFactory {
+ @Override
+ public Extractor[] createExtractors() {
+ // Only create TsExtractor since we only target MPEG2TS stream.
+ Extractor[] extractors = {
+ new TsExtractor(
+ TsExtractor.MODE_SINGLE_PMT,
+ new TimestampAdjuster(0),
+ new DefaultTsPayloadReaderFactory(
+ DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES))
+ };
+ return extractors;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java b/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java
new file mode 100644
index 00000000..e10a2991
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java
@@ -0,0 +1,632 @@
+/*
+ * 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.exoplayer;
+
+import android.net.Uri;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.SystemClock;
+import android.support.annotation.VisibleForTesting;
+import android.util.Pair;
+import com.android.tv.tuner.exoplayer.audio.MpegTsDefaultAudioTrackRenderer;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer;
+import com.android.tv.tuner.exoplayer.buffer.SimpleSampleBuffer;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.upstream.DataSource;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.FormatHolder;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import com.google.android.exoplayer2.source.ExtractorMediaSource;
+import com.google.android.exoplayer2.source.ExtractorMediaSource.EventListener;
+import com.google.android.exoplayer2.source.MediaPeriod;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.SampleStream;
+import com.google.android.exoplayer2.source.TrackGroupArray;
+import com.google.android.exoplayer2.trackselection.FixedTrackSelection;
+import com.google.android.exoplayer2.trackselection.TrackSelection;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.upstream.DefaultAllocator;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A class that extracts samples from a live broadcast stream while storing the sample on the disk.
+ * For demux, this class relies on {@link com.google.android.exoplayer.extractor.ts.TsExtractor}.
+ */
+public class ExoPlayerSampleExtractor implements SampleExtractor {
+ private static final String TAG = "ExoPlayerSampleExtracto";
+
+ private static final int INVALID_TRACK_INDEX = -1;
+ private final HandlerThread mSourceReaderThread;
+ private final long mId;
+
+ private final Handler.Callback mSourceReaderWorker;
+
+ private BufferManager.SampleBuffer mSampleBuffer;
+ private Handler mSourceReaderHandler;
+ private volatile boolean mPrepared;
+ private AtomicBoolean mOnCompletionCalled = new AtomicBoolean();
+ private IOException mExceptionOnPrepare;
+ private List<MediaFormat> mTrackFormats;
+ private int mVideoTrackIndex = INVALID_TRACK_INDEX;
+ private boolean mVideoTrackMet;
+ private long mBaseSamplePts = Long.MIN_VALUE;
+ private HashMap<Integer, Long> mLastExtractedPositionUsMap = new HashMap<>();
+ private final List<Pair<Integer, SampleHolder>> mPendingSamples = new ArrayList<>();
+ private OnCompletionListener mOnCompletionListener;
+ private Handler mOnCompletionListenerHandler;
+ private IOException mError;
+
+ public ExoPlayerSampleExtractor(
+ Uri uri,
+ final DataSource source,
+ BufferManager bufferManager,
+ PlaybackBufferListener bufferListener,
+ boolean isRecording) {
+ this(
+ uri,
+ source,
+ bufferManager,
+ bufferListener,
+ isRecording,
+ Looper.myLooper(),
+ new HandlerThread("SourceReaderThread"));
+ }
+
+ @VisibleForTesting
+ public ExoPlayerSampleExtractor(
+ Uri uri,
+ DataSource source,
+ BufferManager bufferManager,
+ PlaybackBufferListener bufferListener,
+ boolean isRecording,
+ Looper workerLooper,
+ HandlerThread sourceReaderThread) {
+ // It'll be used as a timeshift file chunk name's prefix.
+ mId = System.currentTimeMillis();
+
+ EventListener eventListener =
+ new EventListener() {
+ @Override
+ public void onLoadError(IOException error) {
+ mError = error;
+ }
+ };
+
+ mSourceReaderThread = sourceReaderThread;
+ mSourceReaderWorker =
+ new SourceReaderWorker(
+ new ExtractorMediaSource(
+ uri,
+ new com.google.android.exoplayer2.upstream.DataSource.Factory() {
+ @Override
+ public com.google.android.exoplayer2.upstream.DataSource
+ createDataSource() {
+ // Returns an adapter implementation for ExoPlayer V2
+ // DataSource interface.
+ return new com.google.android.exoplayer2.upstream
+ .DataSource() {
+ @Override
+ public long open(DataSpec dataSpec) throws IOException {
+ return source.open(
+ new com.google.android.exoplayer.upstream
+ .DataSpec(
+ dataSpec.uri,
+ dataSpec.postBody,
+ dataSpec.absoluteStreamPosition,
+ dataSpec.position,
+ dataSpec.length,
+ dataSpec.key,
+ dataSpec.flags));
+ }
+
+ @Override
+ public int read(
+ byte[] buffer, int offset, int readLength)
+ throws IOException {
+ return source.read(buffer, offset, readLength);
+ }
+
+ @Override
+ public Uri getUri() {
+ return null;
+ }
+
+ @Override
+ public void close() throws IOException {
+ source.close();
+ }
+ };
+ }
+ },
+ new ExoPlayerExtractorsFactory(),
+ new Handler(workerLooper),
+ eventListener));
+ if (isRecording) {
+ mSampleBuffer =
+ new RecordingSampleBuffer(
+ bufferManager,
+ bufferListener,
+ false,
+ RecordingSampleBuffer.BUFFER_REASON_RECORDING);
+ } else {
+ if (bufferManager == null) {
+ mSampleBuffer = new SimpleSampleBuffer(bufferListener);
+ } else {
+ mSampleBuffer =
+ new RecordingSampleBuffer(
+ bufferManager,
+ bufferListener,
+ true,
+ RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK);
+ }
+ }
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) {
+ mOnCompletionListener = listener;
+ mOnCompletionListenerHandler = handler;
+ }
+
+ private class SourceReaderWorker implements Handler.Callback, MediaPeriod.Callback {
+ public static final int MSG_PREPARE = 1;
+ public static final int MSG_FETCH_SAMPLES = 2;
+ public static final int MSG_RELEASE = 3;
+ private static final int RETRY_INTERVAL_MS = 50;
+
+ private final MediaSource mSampleSource;
+ private MediaPeriod mMediaPeriod;
+ private SampleStream[] mStreams;
+ private boolean[] mTrackMetEos;
+ private boolean mMetEos = false;
+ private long mCurrentPosition;
+ private DecoderInputBuffer mDecoderInputBuffer;
+ private SampleHolder mSampleHolder;
+ private boolean mPrepareRequested;
+
+ public SourceReaderWorker(MediaSource sampleSource) {
+ mSampleSource = sampleSource;
+ mSampleSource.prepareSource(
+ null,
+ false,
+ new MediaSource.Listener() {
+ @Override
+ public void onSourceInfoRefreshed(
+ MediaSource source, Timeline timeline, Object manifest) {
+ // Dynamic stream change is not supported yet. b/28169263
+ // For now, this will cause EOS and playback reset.
+ }
+ });
+ mDecoderInputBuffer =
+ new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL);
+ }
+
+ MediaFormat convertFormat(Format format) {
+ if (format.sampleMimeType.startsWith("audio/")) {
+ return MediaFormat.createAudioFormat(
+ format.id,
+ format.sampleMimeType,
+ format.bitrate,
+ format.maxInputSize,
+ com.google.android.exoplayer.C.UNKNOWN_TIME_US,
+ format.channelCount,
+ format.sampleRate,
+ format.initializationData,
+ format.language,
+ format.pcmEncoding);
+ } else if (format.sampleMimeType.startsWith("video/")) {
+ return MediaFormat.createVideoFormat(
+ format.id,
+ format.sampleMimeType,
+ format.bitrate,
+ format.maxInputSize,
+ com.google.android.exoplayer.C.UNKNOWN_TIME_US,
+ format.width,
+ format.height,
+ format.initializationData,
+ format.rotationDegrees,
+ format.pixelWidthHeightRatio,
+ format.projectionData,
+ format.stereoMode,
+ null // colorInfo
+ );
+ } else if (format.sampleMimeType.endsWith("/cea-608")
+ || format.sampleMimeType.startsWith("text/")) {
+ return MediaFormat.createTextFormat(
+ format.id,
+ format.sampleMimeType,
+ format.bitrate,
+ com.google.android.exoplayer.C.UNKNOWN_TIME_US,
+ format.language);
+ } else {
+ return MediaFormat.createFormatForMimeType(
+ format.id,
+ format.sampleMimeType,
+ format.bitrate,
+ com.google.android.exoplayer.C.UNKNOWN_TIME_US);
+ }
+ }
+
+ @Override
+ public void onPrepared(MediaPeriod mediaPeriod) {
+ if (mMediaPeriod == null) {
+ // This instance is already released while the extractor is preparing.
+ return;
+ }
+ TrackSelection.Factory selectionFactory = new FixedTrackSelection.Factory();
+ TrackGroupArray trackGroupArray = mMediaPeriod.getTrackGroups();
+ TrackSelection[] selections = new TrackSelection[trackGroupArray.length];
+ for (int i = 0; i < selections.length; ++i) {
+ selections[i] = selectionFactory.createTrackSelection(trackGroupArray.get(i), 0);
+ }
+ boolean[] retain = new boolean[trackGroupArray.length];
+ boolean[] reset = new boolean[trackGroupArray.length];
+ mStreams = new SampleStream[trackGroupArray.length];
+ mMediaPeriod.selectTracks(selections, retain, mStreams, reset, 0);
+ if (mTrackFormats == null) {
+ int trackCount = trackGroupArray.length;
+ mTrackMetEos = new boolean[trackCount];
+ List<MediaFormat> trackFormats = new ArrayList<>();
+ int videoTrackCount = 0;
+ for (int i = 0; i < trackCount; i++) {
+ Format format = trackGroupArray.get(i).getFormat(0);
+ if (format.sampleMimeType.startsWith("video/")) {
+ videoTrackCount++;
+ mVideoTrackIndex = i;
+ }
+ trackFormats.add(convertFormat(format));
+ }
+ if (videoTrackCount > 1) {
+ // Disable dropping samples when there are multiple video tracks.
+ mVideoTrackIndex = INVALID_TRACK_INDEX;
+ }
+ mTrackFormats = trackFormats;
+ List<String> ids = new ArrayList<>();
+ for (int i = 0; i < mTrackFormats.size(); i++) {
+ ids.add(String.format(Locale.ENGLISH, "%s_%x", Long.toHexString(mId), i));
+ }
+ try {
+ mSampleBuffer.init(ids, mTrackFormats);
+ } catch (IOException e) {
+ // In this case, we will not schedule any further operation.
+ // mExceptionOnPrepare will be notified to ExoPlayer, and ExoPlayer will
+ // call release() eventually.
+ mExceptionOnPrepare = e;
+ return;
+ }
+ mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES);
+ mPrepared = true;
+ }
+ }
+
+ @Override
+ public void onContinueLoadingRequested(MediaPeriod source) {
+ source.continueLoading(mCurrentPosition);
+ }
+
+ @Override
+ public boolean handleMessage(Message message) {
+ switch (message.what) {
+ case MSG_PREPARE:
+ if (!mPrepareRequested) {
+ mPrepareRequested = true;
+ mMediaPeriod =
+ mSampleSource.createPeriod(
+ new MediaSource.MediaPeriodId(0),
+ new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE));
+ mMediaPeriod.prepare(this, 0);
+ try {
+ mMediaPeriod.maybeThrowPrepareError();
+ } catch (IOException e) {
+ mError = e;
+ }
+ }
+ return true;
+ case MSG_FETCH_SAMPLES:
+ boolean didSomething = false;
+ ConditionVariable conditionVariable = new ConditionVariable();
+ int trackCount = mStreams.length;
+ for (int i = 0; i < trackCount; ++i) {
+ if (!mTrackMetEos[i]
+ && C.RESULT_NOTHING_READ != fetchSample(i, conditionVariable)) {
+ if (mMetEos) {
+ // If mMetEos was on during fetchSample() due to an error,
+ // fetching from other tracks is not necessary.
+ break;
+ }
+ didSomething = true;
+ }
+ }
+ mMediaPeriod.continueLoading(mCurrentPosition);
+ if (!mMetEos) {
+ if (didSomething) {
+ mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES);
+ } else {
+ mSourceReaderHandler.sendEmptyMessageDelayed(
+ MSG_FETCH_SAMPLES, RETRY_INTERVAL_MS);
+ }
+ } else {
+ notifyCompletionIfNeeded(false);
+ }
+ return true;
+ case MSG_RELEASE:
+ if (mMediaPeriod != null) {
+ mSampleSource.releasePeriod(mMediaPeriod);
+ mSampleSource.releaseSource();
+ mMediaPeriod = null;
+ }
+ cleanUp();
+ mSourceReaderHandler.removeCallbacksAndMessages(null);
+ return true;
+ default: // fall out
+ }
+ return false;
+ }
+
+ private int fetchSample(int track, ConditionVariable conditionVariable) {
+ FormatHolder dummyFormatHolder = new FormatHolder();
+ mDecoderInputBuffer.clear();
+ int ret = mStreams[track].readData(dummyFormatHolder, mDecoderInputBuffer, false);
+ if (ret == C.RESULT_BUFFER_READ
+ // Double-check if the extractor provided the data to prevent NPE. b/33758354
+ && mDecoderInputBuffer.data != null) {
+ if (mCurrentPosition < mDecoderInputBuffer.timeUs) {
+ mCurrentPosition = mDecoderInputBuffer.timeUs;
+ }
+ if (mMediaPeriod != null) {
+ mMediaPeriod.discardBuffer(mCurrentPosition, false);
+ }
+ try {
+ Long lastExtractedPositionUs = mLastExtractedPositionUsMap.get(track);
+ if (lastExtractedPositionUs == null) {
+ mLastExtractedPositionUsMap.put(track, mDecoderInputBuffer.timeUs);
+ } else {
+ mLastExtractedPositionUsMap.put(
+ track,
+ Math.max(lastExtractedPositionUs, mDecoderInputBuffer.timeUs));
+ }
+ queueSample(track, conditionVariable);
+ } catch (IOException e) {
+ mLastExtractedPositionUsMap.clear();
+ mMetEos = true;
+ mSampleBuffer.setEos();
+ }
+ } else if (ret == C.RESULT_END_OF_INPUT) {
+ mTrackMetEos[track] = true;
+ for (int i = 0; i < mTrackMetEos.length; ++i) {
+ if (!mTrackMetEos[i]) {
+ break;
+ }
+ if (i == mTrackMetEos.length - 1) {
+ mMetEos = true;
+ mSampleBuffer.setEos();
+ }
+ }
+ }
+ // TODO: Handle C.RESULT_FORMAT_READ for dynamic resolution change. b/28169263
+ return ret;
+ }
+
+ private void queueSample(int index, ConditionVariable conditionVariable)
+ throws IOException {
+ if (mVideoTrackIndex != INVALID_TRACK_INDEX) {
+ if (!mVideoTrackMet) {
+ if (index != mVideoTrackIndex) {
+ SampleHolder sample =
+ new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL);
+ mSampleHolder.flags =
+ (mDecoderInputBuffer.isKeyFrame()
+ ? com.google.android.exoplayer.C.SAMPLE_FLAG_SYNC
+ : 0)
+ | (mDecoderInputBuffer.isDecodeOnly()
+ ? com.google
+ .android
+ .exoplayer
+ .C
+ .SAMPLE_FLAG_DECODE_ONLY
+ : 0);
+ sample.timeUs = mDecoderInputBuffer.timeUs;
+ sample.size = mDecoderInputBuffer.data.position();
+ sample.ensureSpaceForWrite(sample.size);
+ mDecoderInputBuffer.flip();
+ sample.data.position(0);
+ sample.data.put(mDecoderInputBuffer.data);
+ sample.data.flip();
+ mPendingSamples.add(new Pair<>(index, sample));
+ return;
+ }
+ mVideoTrackMet = true;
+ mBaseSamplePts =
+ mDecoderInputBuffer.timeUs
+ - MpegTsDefaultAudioTrackRenderer
+ .INITIAL_AUDIO_BUFFERING_TIME_US;
+ for (Pair<Integer, SampleHolder> pair : mPendingSamples) {
+ if (pair.second.timeUs >= mBaseSamplePts) {
+ mSampleBuffer.writeSample(pair.first, pair.second, conditionVariable);
+ }
+ }
+ mPendingSamples.clear();
+ } else {
+ if (mDecoderInputBuffer.timeUs < mBaseSamplePts && mVideoTrackIndex != index) {
+ return;
+ }
+ }
+ }
+ // Copy the decoder input to the sample holder.
+ mSampleHolder.clearData();
+ mSampleHolder.flags =
+ (mDecoderInputBuffer.isKeyFrame()
+ ? com.google.android.exoplayer.C.SAMPLE_FLAG_SYNC
+ : 0)
+ | (mDecoderInputBuffer.isDecodeOnly()
+ ? com.google.android.exoplayer.C.SAMPLE_FLAG_DECODE_ONLY
+ : 0);
+ mSampleHolder.timeUs = mDecoderInputBuffer.timeUs;
+ mSampleHolder.size = mDecoderInputBuffer.data.position();
+ mSampleHolder.ensureSpaceForWrite(mSampleHolder.size);
+ mDecoderInputBuffer.flip();
+ mSampleHolder.data.position(0);
+ mSampleHolder.data.put(mDecoderInputBuffer.data);
+ mSampleHolder.data.flip();
+ long writeStartTimeNs = SystemClock.elapsedRealtimeNanos();
+ mSampleBuffer.writeSample(index, mSampleHolder, conditionVariable);
+
+ // Checks whether the storage has enough bandwidth for recording samples.
+ if (mSampleBuffer.isWriteSpeedSlow(
+ mSampleHolder.size, SystemClock.elapsedRealtimeNanos() - writeStartTimeNs)) {
+ mSampleBuffer.handleWriteSpeedSlow();
+ }
+ }
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mError != null) {
+ IOException e = mError;
+ mError = null;
+ throw e;
+ }
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ if (!mSourceReaderThread.isAlive()) {
+ mSourceReaderThread.start();
+ mSourceReaderHandler =
+ new Handler(mSourceReaderThread.getLooper(), mSourceReaderWorker);
+ mSourceReaderHandler.sendEmptyMessage(SourceReaderWorker.MSG_PREPARE);
+ }
+ if (mExceptionOnPrepare != null) {
+ throw mExceptionOnPrepare;
+ }
+ return mPrepared;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ outMediaFormatHolder.format = mTrackFormats.get(track);
+ outMediaFormatHolder.drmInitData = null;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ mSampleBuffer.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ mSampleBuffer.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleBuffer.getBufferedPositionUs();
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleBuffer.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleBuffer.seekTo(positionUs);
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ return mSampleBuffer.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public void release() {
+ if (mSourceReaderThread.isAlive()) {
+ mSourceReaderHandler.removeCallbacksAndMessages(null);
+ mSourceReaderHandler.sendEmptyMessage(SourceReaderWorker.MSG_RELEASE);
+ mSourceReaderThread.quitSafely();
+ // Return early in this case so that session worker can start working on the next
+ // request as early as it can. The clean up will be done in the reader thread while
+ // handling MSG_RELEASE.
+ } else {
+ cleanUp();
+ }
+ }
+
+ private void cleanUp() {
+ boolean result = true;
+ try {
+ if (mSampleBuffer != null) {
+ mSampleBuffer.release();
+ mSampleBuffer = null;
+ }
+ } catch (IOException e) {
+ result = false;
+ }
+ notifyCompletionIfNeeded(result);
+ setOnCompletionListener(null, null);
+ }
+
+ private void notifyCompletionIfNeeded(final boolean result) {
+ if (!mOnCompletionCalled.getAndSet(true)) {
+ final OnCompletionListener listener = mOnCompletionListener;
+ final long lastExtractedPositionUs = getLastExtractedPositionUs();
+ if (mOnCompletionListenerHandler != null && mOnCompletionListener != null) {
+ mOnCompletionListenerHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ listener.onCompletion(result, lastExtractedPositionUs);
+ }
+ });
+ }
+ }
+ }
+
+ private long getLastExtractedPositionUs() {
+ long lastExtractedPositionUs = Long.MIN_VALUE;
+ for (Map.Entry<Integer, Long> entry : mLastExtractedPositionUsMap.entrySet()) {
+ if (mVideoTrackIndex != entry.getKey()) {
+ lastExtractedPositionUs = Math.max(lastExtractedPositionUs, entry.getValue());
+ }
+ }
+ if (lastExtractedPositionUs == Long.MIN_VALUE) {
+ lastExtractedPositionUs = com.google.android.exoplayer.C.UNKNOWN_TIME_US;
+ }
+ return lastExtractedPositionUs;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java b/tuner/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java
new file mode 100644
index 00000000..e7224422
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java
@@ -0,0 +1,139 @@
+/*
+ * 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.exoplayer;
+
+import android.os.Handler;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.MediaFormatUtil;
+import com.google.android.exoplayer.SampleHolder;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A class that plays a recorded stream without using {@link android.media.MediaExtractor}, since
+ * all samples are extracted and stored to the permanent storage already.
+ */
+public class FileSampleExtractor implements SampleExtractor {
+ private static final String TAG = "FileSampleExtractor";
+ private static final boolean DEBUG = false;
+
+ private int mTrackCount;
+ private boolean mReleased;
+
+ private final List<MediaFormat> mTrackFormats = new ArrayList<>();
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+ private BufferManager.SampleBuffer mSampleBuffer;
+
+ public FileSampleExtractor(BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ mTrackCount = -1;
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ // Do nothing.
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ List<BufferManager.TrackFormat> trackFormatList = mBufferManager.readTrackInfoFiles();
+ if (trackFormatList == null || trackFormatList.isEmpty()) {
+ throw new IOException("Cannot find meta files for the recording.");
+ }
+ mTrackCount = trackFormatList.size();
+ List<String> ids = new ArrayList<>();
+ mTrackFormats.clear();
+ for (int i = 0; i < mTrackCount; ++i) {
+ BufferManager.TrackFormat trackFormat = trackFormatList.get(i);
+ ids.add(trackFormat.trackId);
+ mTrackFormats.add(MediaFormatUtil.createMediaFormat(trackFormat.format));
+ }
+ mSampleBuffer =
+ new RecordingSampleBuffer(
+ mBufferManager,
+ mBufferListener,
+ true,
+ RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK);
+ mSampleBuffer.init(ids, mTrackFormats);
+ return true;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ outMediaFormatHolder.format = mTrackFormats.get(track);
+ outMediaFormatHolder.drmInitData = null;
+ }
+
+ @Override
+ public void release() {
+ if (!mReleased) {
+ if (mSampleBuffer != null) {
+ try {
+ mSampleBuffer.release();
+ } catch (IOException e) {
+ // Do nothing. Playback ends now.
+ }
+ }
+ }
+ mReleased = true;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ mSampleBuffer.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ mSampleBuffer.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleBuffer.getBufferedPositionUs();
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleBuffer.seekTo(positionUs);
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ return mSampleBuffer.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleBuffer.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) {}
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java
new file mode 100644
index 00000000..a49cbfaf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java
@@ -0,0 +1,672 @@
+/*
+ * 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.exoplayer;
+
+import android.content.Context;
+import android.media.AudioFormat;
+import android.media.MediaCodec.CryptoException;
+import android.media.PlaybackParams;
+import android.os.Handler;
+import android.support.annotation.IntDef;
+import android.view.Surface;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.data.Cea708Data;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.exoplayer.audio.MpegTsDefaultAudioTrackRenderer;
+import com.android.tv.tuner.exoplayer.audio.MpegTsMediaCodecAudioTrackRenderer;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsDataSourceManager;
+import com.android.tv.tuner.tvinput.EventDetector;
+import com.android.tv.tuner.tvinput.TunerDebug;
+import com.google.android.exoplayer.DummyTrackRenderer;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.ExoPlayer;
+import com.google.android.exoplayer.MediaCodecAudioTrackRenderer;
+import com.google.android.exoplayer.MediaCodecTrackRenderer.DecoderInitializationException;
+import com.google.android.exoplayer.MediaCodecVideoTrackRenderer;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.google.android.exoplayer.audio.AudioTrack;
+import com.google.android.exoplayer.upstream.DataSource;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** MPEG-2 TS stream player implementation using ExoPlayer. */
+public class MpegTsPlayer
+ implements ExoPlayer.Listener,
+ MediaCodecVideoTrackRenderer.EventListener,
+ MpegTsDefaultAudioTrackRenderer.EventListener,
+ MpegTsMediaCodecAudioTrackRenderer.Ac3EventListener {
+ private int mCaptionServiceNumber = Cea708Data.EMPTY_SERVICE_NUMBER;
+
+ /** Interface definition for building specific track renderers. */
+ public interface RendererBuilder {
+ void buildRenderers(
+ MpegTsPlayer mpegTsPlayer,
+ DataSource dataSource,
+ boolean hasSoftwareAudioDecoder,
+ RendererBuilderCallback callback);
+ }
+
+ /** Interface definition for {@link RendererBuilder#buildRenderers} to notify the result. */
+ public interface RendererBuilderCallback {
+ void onRenderers(String[][] trackNames, TrackRenderer[] renderers);
+
+ void onRenderersError(Exception e);
+ }
+
+ /** Interface definition for a callback to be notified of changes in player state. */
+ public interface Listener {
+ void onStateChanged(boolean playWhenReady, int playbackState);
+
+ void onError(Exception e);
+
+ void onVideoSizeChanged(int width, int height, float pixelWidthHeightRatio);
+
+ void onDrawnToSurface(MpegTsPlayer player, Surface surface);
+
+ void onAudioUnplayable();
+
+ void onSmoothTrickplayForceStopped();
+ }
+
+ /** Interface definition for a callback to be notified of changes on video display. */
+ public interface VideoEventListener {
+ /** Notifies the caption event. */
+ void onEmitCaptionEvent(CaptionEvent event);
+
+ /** Notifies clearing up whole closed caption event. */
+ void onClearCaptionEvent();
+
+ /** Notifies the discovered caption service number. */
+ void onDiscoverCaptionServiceNumber(int serviceNumber);
+ }
+
+ public static final int RENDERER_COUNT = 3;
+ public static final int MIN_BUFFER_MS = 0;
+ public static final int MIN_REBUFFER_MS = 500;
+
+ @IntDef({TRACK_TYPE_VIDEO, TRACK_TYPE_AUDIO, TRACK_TYPE_TEXT})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface TrackType {}
+
+ public static final int TRACK_TYPE_VIDEO = 0;
+ public static final int TRACK_TYPE_AUDIO = 1;
+ public static final int TRACK_TYPE_TEXT = 2;
+
+ @IntDef({
+ RENDERER_BUILDING_STATE_IDLE,
+ RENDERER_BUILDING_STATE_BUILDING,
+ RENDERER_BUILDING_STATE_BUILT
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface RendererBuildingState {}
+
+ private static final int RENDERER_BUILDING_STATE_IDLE = 1;
+ private static final int RENDERER_BUILDING_STATE_BUILDING = 2;
+ private static final int RENDERER_BUILDING_STATE_BUILT = 3;
+
+ private static final float MAX_SMOOTH_TRICKPLAY_SPEED = 9.0f;
+ private static final float MIN_SMOOTH_TRICKPLAY_SPEED = 0.1f;
+
+ private final RendererBuilder mRendererBuilder;
+ private final ExoPlayer mPlayer;
+ private final Handler mMainHandler;
+ private final AudioCapabilities mAudioCapabilities;
+ private final TsDataSourceManager mSourceManager;
+
+ private Listener mListener;
+ @RendererBuildingState private int mRendererBuildingState;
+
+ private Surface mSurface;
+ private TsDataSource mDataSource;
+ private InternalRendererBuilderCallback mBuilderCallback;
+ private TrackRenderer mVideoRenderer;
+ private TrackRenderer mAudioRenderer;
+ private Cea708TextTrackRenderer mTextRenderer;
+ private final Cea708TextTrackRenderer.CcListener mCcListener;
+ private VideoEventListener mVideoEventListener;
+ private boolean mTrickplayRunning;
+ private float mVolume;
+
+ /**
+ * Creates MPEG2-TS stream player.
+ *
+ * @param rendererBuilder the builder of track renderers
+ * @param handler the handler for the playback events in track renderers
+ * @param sourceManager the manager for {@link DataSource}
+ * @param capabilities the {@link AudioCapabilities} of the current device
+ * @param listener the listener for playback state changes
+ */
+ public MpegTsPlayer(
+ RendererBuilder rendererBuilder,
+ Handler handler,
+ TsDataSourceManager sourceManager,
+ AudioCapabilities capabilities,
+ Listener listener) {
+ mRendererBuilder = rendererBuilder;
+ mPlayer = ExoPlayer.Factory.newInstance(RENDERER_COUNT, MIN_BUFFER_MS, MIN_REBUFFER_MS);
+ mPlayer.addListener(this);
+ mMainHandler = handler;
+ mAudioCapabilities = capabilities;
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ mCcListener = new MpegTsCcListener();
+ mSourceManager = sourceManager;
+ mListener = listener;
+ }
+
+ /**
+ * Sets the video event listener.
+ *
+ * @param videoEventListener the listener for video events
+ */
+ public void setVideoEventListener(VideoEventListener videoEventListener) {
+ mVideoEventListener = videoEventListener;
+ }
+
+ /**
+ * Sets the closed caption service number.
+ *
+ * @param captionServiceNumber the service number of CEA-708 closed caption
+ */
+ public void setCaptionServiceNumber(int captionServiceNumber) {
+ mCaptionServiceNumber = captionServiceNumber;
+ if (mTextRenderer != null) {
+ mPlayer.sendMessage(
+ mTextRenderer,
+ Cea708TextTrackRenderer.MSG_SERVICE_NUMBER,
+ mCaptionServiceNumber);
+ }
+ }
+
+ /**
+ * Sets the surface for the player.
+ *
+ * @param surface the {@link Surface} to render video
+ */
+ public void setSurface(Surface surface) {
+ mSurface = surface;
+ pushSurface(false);
+ }
+
+ /** Returns the current surface of the player. */
+ public Surface getSurface() {
+ return mSurface;
+ }
+
+ /** Clears the surface and waits until the surface is being cleaned. */
+ public void blockingClearSurface() {
+ mSurface = null;
+ pushSurface(true);
+ }
+
+ /**
+ * Creates renderers and {@link DataSource} and initializes player.
+ *
+ * @param context a {@link Context} instance
+ * @param channel to play
+ * @param hasSoftwareAudioDecoder {@code true} if there is connected software decoder
+ * @param eventListener for program information which will be scanned from MPEG2-TS stream
+ * @return true when everything is created and initialized well, false otherwise
+ */
+ public boolean prepare(
+ Context context,
+ TunerChannel channel,
+ boolean hasSoftwareAudioDecoder,
+ EventDetector.EventListener eventListener) {
+ TsDataSource source = null;
+ if (channel != null) {
+ source = mSourceManager.createDataSource(context, channel, eventListener);
+ if (source == null) {
+ return false;
+ }
+ }
+ mDataSource = source;
+ if (mRendererBuildingState == RENDERER_BUILDING_STATE_BUILT) {
+ mPlayer.stop();
+ }
+ if (mBuilderCallback != null) {
+ mBuilderCallback.cancel();
+ }
+ mRendererBuildingState = RENDERER_BUILDING_STATE_BUILDING;
+ mBuilderCallback = new InternalRendererBuilderCallback();
+ mRendererBuilder.buildRenderers(this, source, hasSoftwareAudioDecoder, mBuilderCallback);
+ return true;
+ }
+
+ /** Returns {@link TsDataSource} which provides MPEG2-TS stream. */
+ public TsDataSource getDataSource() {
+ return mDataSource;
+ }
+
+ private void onRenderers(TrackRenderer[] renderers) {
+ mBuilderCallback = null;
+ for (int i = 0; i < RENDERER_COUNT; i++) {
+ if (renderers[i] == null) {
+ // Convert a null renderer to a dummy renderer.
+ renderers[i] = new DummyTrackRenderer();
+ }
+ }
+ mVideoRenderer = renderers[TRACK_TYPE_VIDEO];
+ mAudioRenderer = renderers[TRACK_TYPE_AUDIO];
+ mTextRenderer = (Cea708TextTrackRenderer) renderers[TRACK_TYPE_TEXT];
+ mTextRenderer.setCcListener(mCcListener);
+ mPlayer.sendMessage(
+ mTextRenderer, Cea708TextTrackRenderer.MSG_SERVICE_NUMBER, mCaptionServiceNumber);
+ mRendererBuildingState = RENDERER_BUILDING_STATE_BUILT;
+ pushSurface(false);
+ mPlayer.prepare(renderers);
+ pushTrackSelection(TRACK_TYPE_VIDEO, true);
+ pushTrackSelection(TRACK_TYPE_AUDIO, true);
+ pushTrackSelection(TRACK_TYPE_TEXT, true);
+ }
+
+ private void onRenderersError(Exception e) {
+ mBuilderCallback = null;
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ if (mListener != null) {
+ mListener.onError(e);
+ }
+ }
+
+ /**
+ * Sets the player state to pause or play.
+ *
+ * @param playWhenReady sets the player state to being ready to play when {@code true}, sets the
+ * player state to being paused when {@code false}
+ */
+ public void setPlayWhenReady(boolean playWhenReady) {
+ mPlayer.setPlayWhenReady(playWhenReady);
+ stopSmoothTrickplay(false);
+ }
+
+ /** Returns true, if trickplay is supported. */
+ public boolean supportSmoothTrickPlay(float playbackSpeed) {
+ return playbackSpeed > MIN_SMOOTH_TRICKPLAY_SPEED
+ && playbackSpeed < MAX_SMOOTH_TRICKPLAY_SPEED;
+ }
+
+ /**
+ * Starts trickplay. It'll be reset, if {@link #seekTo} or {@link #setPlayWhenReady} is called.
+ */
+ public void startSmoothTrickplay(PlaybackParams playbackParams) {
+ SoftPreconditions.checkState(supportSmoothTrickPlay(playbackParams.getSpeed()));
+ mPlayer.setPlayWhenReady(true);
+ mTrickplayRunning = true;
+ if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MpegTsDefaultAudioTrackRenderer.MSG_SET_PLAYBACK_SPEED,
+ playbackParams.getSpeed());
+ } else {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MediaCodecAudioTrackRenderer.MSG_SET_PLAYBACK_PARAMS,
+ playbackParams);
+ }
+ }
+
+ private void stopSmoothTrickplay(boolean calledBySeek) {
+ if (mTrickplayRunning) {
+ mTrickplayRunning = false;
+ if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MpegTsDefaultAudioTrackRenderer.MSG_SET_PLAYBACK_SPEED,
+ 1.0f);
+ } else {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MediaCodecAudioTrackRenderer.MSG_SET_PLAYBACK_PARAMS,
+ new PlaybackParams().setSpeed(1.0f));
+ }
+ if (!calledBySeek) {
+ mPlayer.seekTo(mPlayer.getCurrentPosition());
+ }
+ }
+ }
+
+ /**
+ * Seeks to the specified position of the current playback.
+ *
+ * @param positionMs the specified position in milli seconds.
+ */
+ public void seekTo(long positionMs) {
+ mPlayer.seekTo(positionMs);
+ stopSmoothTrickplay(true);
+ }
+
+ /** Releases the player. */
+ public void release() {
+ if (mDataSource != null) {
+ mSourceManager.releaseDataSource(mDataSource);
+ mDataSource = null;
+ }
+ if (mBuilderCallback != null) {
+ mBuilderCallback.cancel();
+ mBuilderCallback = null;
+ }
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ mSurface = null;
+ mListener = null;
+ mPlayer.release();
+ }
+
+ /** Returns the current status of the player. */
+ public int getPlaybackState() {
+ if (mRendererBuildingState == RENDERER_BUILDING_STATE_BUILDING) {
+ return ExoPlayer.STATE_PREPARING;
+ }
+ return mPlayer.getPlaybackState();
+ }
+
+ /** Returns {@code true} when the player is prepared to play, {@code false} otherwise. */
+ public boolean isPrepared() {
+ int state = getPlaybackState();
+ return state == ExoPlayer.STATE_READY || state == ExoPlayer.STATE_BUFFERING;
+ }
+
+ /** Returns {@code true} when the player is being ready to play, {@code false} otherwise. */
+ public boolean isPlaying() {
+ int state = getPlaybackState();
+ return (state == ExoPlayer.STATE_READY || state == ExoPlayer.STATE_BUFFERING)
+ && mPlayer.getPlayWhenReady();
+ }
+
+ /** Returns {@code true} when the player is buffering, {@code false} otherwise. */
+ public boolean isBuffering() {
+ return getPlaybackState() == ExoPlayer.STATE_BUFFERING;
+ }
+
+ /** Returns the current position of the playback in milli seconds. */
+ public long getCurrentPosition() {
+ return mPlayer.getCurrentPosition();
+ }
+
+ /** Returns the total duration of the playback. */
+ public long getDuration() {
+ return mPlayer.getDuration();
+ }
+
+ /**
+ * Returns {@code true} when the player is being ready to play, {@code false} when the player is
+ * paused.
+ */
+ public boolean getPlayWhenReady() {
+ return mPlayer.getPlayWhenReady();
+ }
+
+ /**
+ * Sets the volume of the audio.
+ *
+ * @param volume see also {@link AudioTrack#setVolume(float)}
+ */
+ public void setVolume(float volume) {
+ mVolume = volume;
+ if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) {
+ mPlayer.sendMessage(
+ mAudioRenderer, MpegTsDefaultAudioTrackRenderer.MSG_SET_VOLUME, volume);
+ } else {
+ mPlayer.sendMessage(
+ mAudioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME, volume);
+ }
+ }
+
+ /**
+ * Enables or disables audio and closed caption.
+ *
+ * @param enable enables the audio and closed caption when {@code true}, disables otherwise.
+ */
+ public void setAudioTrackAndClosedCaption(boolean enable) {
+ if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MpegTsDefaultAudioTrackRenderer.MSG_SET_AUDIO_TRACK,
+ enable ? 1 : 0);
+ } else {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MediaCodecAudioTrackRenderer.MSG_SET_VOLUME,
+ enable ? mVolume : 0.0f);
+ }
+ mPlayer.sendMessage(
+ mTextRenderer, Cea708TextTrackRenderer.MSG_ENABLE_CLOSED_CAPTION, enable);
+ }
+
+ /** Returns {@code true} when AC3 audio can be played, {@code false} otherwise. */
+ public boolean isAc3Playable() {
+ return mAudioCapabilities != null
+ && mAudioCapabilities.supportsEncoding(AudioFormat.ENCODING_AC3);
+ }
+
+ /** Notifies when the audio cannot be played by the current device. */
+ public void onAudioUnplayable() {
+ if (mListener != null) {
+ mListener.onAudioUnplayable();
+ }
+ }
+
+ /** Returns {@code true} if the player has any video track, {@code false} otherwise. */
+ public boolean hasVideo() {
+ return mPlayer.getTrackCount(TRACK_TYPE_VIDEO) > 0;
+ }
+
+ /** Returns {@code true} if the player has any audio trock, {@code false} otherwise. */
+ public boolean hasAudio() {
+ return mPlayer.getTrackCount(TRACK_TYPE_AUDIO) > 0;
+ }
+
+ /** Returns the number of tracks exposed by the specified renderer. */
+ public int getTrackCount(int rendererIndex) {
+ return mPlayer.getTrackCount(rendererIndex);
+ }
+
+ /** Selects a track for the specified renderer. */
+ public void setSelectedTrack(int rendererIndex, int trackIndex) {
+ if (trackIndex >= getTrackCount(rendererIndex)) {
+ return;
+ }
+ mPlayer.setSelectedTrack(rendererIndex, trackIndex);
+ }
+
+ /**
+ * Returns the index of the currently selected track for the specified renderer.
+ *
+ * @param rendererIndex The index of the renderer.
+ * @return The selected track. A negative value or a value greater than or equal to the
+ * renderer's track count indicates that the renderer is disabled.
+ */
+ public int getSelectedTrack(int rendererIndex) {
+ return mPlayer.getSelectedTrack(rendererIndex);
+ }
+
+ /**
+ * Returns the format of a track.
+ *
+ * @param rendererIndex The index of the renderer.
+ * @param trackIndex The index of the track.
+ * @return The format of the track.
+ */
+ public MediaFormat getTrackFormat(int rendererIndex, int trackIndex) {
+ return mPlayer.getTrackFormat(rendererIndex, trackIndex);
+ }
+
+ /** Gets the main handler of the player. */
+ /* package */ Handler getMainHandler() {
+ return mMainHandler;
+ }
+
+ @Override
+ public void onPlayerStateChanged(boolean playWhenReady, int state) {
+ if (mListener == null) {
+ return;
+ }
+ mListener.onStateChanged(playWhenReady, state);
+ if (state == ExoPlayer.STATE_READY
+ && mPlayer.getTrackCount(TRACK_TYPE_VIDEO) > 0
+ && playWhenReady) {
+ MediaFormat format = mPlayer.getTrackFormat(TRACK_TYPE_VIDEO, 0);
+ mListener.onVideoSizeChanged(format.width, format.height, format.pixelWidthHeightRatio);
+ }
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException exception) {
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ if (mListener != null) {
+ mListener.onError(exception);
+ }
+ }
+
+ @Override
+ public void onVideoSizeChanged(
+ int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {
+ if (mListener != null) {
+ mListener.onVideoSizeChanged(width, height, pixelWidthHeightRatio);
+ }
+ }
+
+ @Override
+ public void onDecoderInitialized(
+ String decoderName, long elapsedRealtimeMs, long initializationDurationMs) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onDecoderInitializationError(DecoderInitializationException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAudioTrackInitializationError(AudioTrack.InitializationException e) {
+ if (mListener != null) {
+ mListener.onAudioUnplayable();
+ }
+ }
+
+ @Override
+ public void onAudioTrackWriteError(AudioTrack.WriteException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAudioTrackUnderrun(
+ int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onCryptoError(CryptoException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onPlayWhenReadyCommitted() {
+ // Do nothing.
+ }
+
+ @Override
+ public void onDrawnToSurface(Surface surface) {
+ if (mListener != null) {
+ mListener.onDrawnToSurface(this, surface);
+ }
+ }
+
+ @Override
+ public void onDroppedFrames(int count, long elapsed) {
+ TunerDebug.notifyVideoFrameDrop(count, elapsed);
+ if (mTrickplayRunning && mListener != null) {
+ mListener.onSmoothTrickplayForceStopped();
+ }
+ }
+
+ @Override
+ public void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e) {
+ if (mTrickplayRunning && mListener != null) {
+ mListener.onSmoothTrickplayForceStopped();
+ }
+ }
+
+ private void pushSurface(boolean blockForSurfacePush) {
+ if (mRendererBuildingState != RENDERER_BUILDING_STATE_BUILT) {
+ return;
+ }
+
+ if (blockForSurfacePush) {
+ mPlayer.blockingSendMessage(
+ mVideoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, mSurface);
+ } else {
+ mPlayer.sendMessage(
+ mVideoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, mSurface);
+ }
+ }
+
+ private void pushTrackSelection(@TrackType int type, boolean allowRendererEnable) {
+ if (mRendererBuildingState != RENDERER_BUILDING_STATE_BUILT) {
+ return;
+ }
+ mPlayer.setSelectedTrack(type, allowRendererEnable ? 0 : -1);
+ }
+
+ private class MpegTsCcListener implements Cea708TextTrackRenderer.CcListener {
+
+ @Override
+ public void emitEvent(CaptionEvent captionEvent) {
+ if (mVideoEventListener != null) {
+ mVideoEventListener.onEmitCaptionEvent(captionEvent);
+ }
+ }
+
+ @Override
+ public void clearCaption() {
+ if (mVideoEventListener != null) {
+ mVideoEventListener.onClearCaptionEvent();
+ }
+ }
+
+ @Override
+ public void discoverServiceNumber(int serviceNumber) {
+ if (mVideoEventListener != null) {
+ mVideoEventListener.onDiscoverCaptionServiceNumber(serviceNumber);
+ }
+ }
+ }
+
+ private class InternalRendererBuilderCallback implements RendererBuilderCallback {
+ private boolean canceled;
+
+ public void cancel() {
+ canceled = true;
+ }
+
+ @Override
+ public void onRenderers(String[][] trackNames, TrackRenderer[] renderers) {
+ if (!canceled) {
+ MpegTsPlayer.this.onRenderers(renderers);
+ }
+ }
+
+ @Override
+ public void onRenderersError(Exception e) {
+ if (!canceled) {
+ MpegTsPlayer.this.onRenderersError(e);
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java
new file mode 100644
index 00000000..774285e9
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java
@@ -0,0 +1,77 @@
+/*
+ * 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.exoplayer;
+
+import android.content.Context;
+import com.android.tv.tuner.TunerFeatures;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer.RendererBuilder;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer.RendererBuilderCallback;
+import com.android.tv.tuner.exoplayer.audio.MpegTsDefaultAudioTrackRenderer;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.upstream.DataSource;
+
+/** Builder for renderer objects for {@link MpegTsPlayer}. */
+public class MpegTsRendererBuilder implements RendererBuilder {
+ private final Context mContext;
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+
+ public MpegTsRendererBuilder(
+ Context context, BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mContext = context;
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ }
+
+ @Override
+ public void buildRenderers(
+ MpegTsPlayer mpegTsPlayer,
+ DataSource dataSource,
+ boolean mHasSoftwareAudioDecoder,
+ RendererBuilderCallback callback) {
+ // Build the video and audio renderers.
+ SampleExtractor extractor =
+ dataSource == null
+ ? new MpegTsSampleExtractor(mBufferManager, mBufferListener)
+ : new MpegTsSampleExtractor(dataSource, mBufferManager, mBufferListener);
+ SampleSource sampleSource = new MpegTsSampleSource(extractor);
+ MpegTsVideoTrackRenderer videoRenderer =
+ new MpegTsVideoTrackRenderer(
+ mContext, sampleSource, mpegTsPlayer.getMainHandler(), mpegTsPlayer);
+ // TODO: Only using MpegTsDefaultAudioTrackRenderer for A/V sync issue. We will use
+ // {@link MpegTsMediaCodecAudioTrackRenderer} when we use ExoPlayer's extractor.
+ TrackRenderer audioRenderer =
+ new MpegTsDefaultAudioTrackRenderer(
+ sampleSource,
+ MediaCodecSelector.DEFAULT,
+ mpegTsPlayer.getMainHandler(),
+ mpegTsPlayer,
+ mHasSoftwareAudioDecoder,
+ !TunerFeatures.AC3_SOFTWARE_DECODE.isEnabled(mContext));
+ Cea708TextTrackRenderer textRenderer = new Cea708TextTrackRenderer(sampleSource);
+
+ TrackRenderer[] renderers = new TrackRenderer[MpegTsPlayer.RENDERER_COUNT];
+ renderers[MpegTsPlayer.TRACK_TYPE_VIDEO] = videoRenderer;
+ renderers[MpegTsPlayer.TRACK_TYPE_AUDIO] = audioRenderer;
+ renderers[MpegTsPlayer.TRACK_TYPE_TEXT] = textRenderer;
+ callback.onRenderers(null, renderers);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java
new file mode 100644
index 00000000..593b576e
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java
@@ -0,0 +1,345 @@
+/*
+ * 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.exoplayer;
+
+import android.net.Uri;
+import android.os.Handler;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.SamplePool;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.upstream.DataSource;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+/** Extracts samples from {@link DataSource} for MPEG-TS streams. */
+public final class MpegTsSampleExtractor implements SampleExtractor {
+ public static final String MIMETYPE_TEXT_CEA_708 = "text/cea-708";
+
+ private static final int CC_BUFFER_SIZE_IN_BYTES = 9600 / 8;
+
+ private final SampleExtractor mSampleExtractor;
+ private final List<MediaFormat> mTrackFormats = new ArrayList<>();
+ private final List<Boolean> mReachedEos = new ArrayList<>();
+ private int mVideoTrackIndex;
+ private final SamplePool mCcSamplePool = new SamplePool();
+ private final List<SampleHolder> mPendingCcSamples = new LinkedList<>();
+
+ private int mCea708TextTrackIndex;
+ private boolean mCea708TextTrackSelected;
+
+ private CcParser mCcParser;
+
+ private void init() {
+ mVideoTrackIndex = -1;
+ mCea708TextTrackIndex = -1;
+ mCea708TextTrackSelected = false;
+ }
+
+ /**
+ * Creates MpegTsSampleExtractor for {@link DataSource}.
+ *
+ * @param source the {@link DataSource} to extract from
+ * @param bufferManager the manager for reading & writing samples backed by physical storage
+ * @param bufferListener the {@link PlaybackBufferListener} to notify buffer storage status
+ * change
+ */
+ public MpegTsSampleExtractor(
+ DataSource source, BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mSampleExtractor =
+ new ExoPlayerSampleExtractor(
+ Uri.EMPTY, source, bufferManager, bufferListener, false);
+ init();
+ }
+
+ /**
+ * Creates MpegTsSampleExtractor for a recorded program.
+ *
+ * @param bufferManager the samples provider which is stored in physical storage
+ * @param bufferListener the {@link PlaybackBufferListener} to notify buffer storage status
+ * change
+ */
+ public MpegTsSampleExtractor(
+ BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mSampleExtractor = new FileSampleExtractor(bufferManager, bufferListener);
+ init();
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mSampleExtractor != null) {
+ mSampleExtractor.maybeThrowError();
+ }
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ if (!mSampleExtractor.prepare()) {
+ return false;
+ }
+ List<MediaFormat> formats = mSampleExtractor.getTrackFormats();
+ int trackCount = formats.size();
+ mTrackFormats.clear();
+ mReachedEos.clear();
+
+ for (int i = 0; i < trackCount; ++i) {
+ mTrackFormats.add(formats.get(i));
+ mReachedEos.add(false);
+ String mime = formats.get(i).mimeType;
+ if (MimeTypes.isVideo(mime) && mVideoTrackIndex == -1) {
+ mVideoTrackIndex = i;
+ if (android.media.MediaFormat.MIMETYPE_VIDEO_MPEG2.equals(mime)) {
+ mCcParser = new Mpeg2CcParser();
+ } else if (android.media.MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) {
+ mCcParser = new H264CcParser();
+ }
+ }
+ }
+
+ if (mVideoTrackIndex != -1) {
+ mCea708TextTrackIndex = trackCount;
+ }
+ if (mCea708TextTrackIndex >= 0) {
+ mTrackFormats.add(
+ MediaFormat.createTextFormat(
+ null, MIMETYPE_TEXT_CEA_708, 0, mTrackFormats.get(0).durationUs, ""));
+ }
+ return true;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ if (index == mCea708TextTrackIndex) {
+ mCea708TextTrackSelected = true;
+ return;
+ }
+ mSampleExtractor.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ if (index == mCea708TextTrackIndex) {
+ mCea708TextTrackSelected = false;
+ return;
+ }
+ mSampleExtractor.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleExtractor.getBufferedPositionUs();
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleExtractor.seekTo(positionUs);
+ for (SampleHolder holder : mPendingCcSamples) {
+ mCcSamplePool.releaseSample(holder);
+ }
+ mPendingCcSamples.clear();
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ if (track != mCea708TextTrackIndex) {
+ mSampleExtractor.getTrackMediaFormat(track, outMediaFormatHolder);
+ }
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ if (track == mCea708TextTrackIndex) {
+ if (mCea708TextTrackSelected && !mPendingCcSamples.isEmpty()) {
+ SampleHolder holder = mPendingCcSamples.remove(0);
+ holder.data.flip();
+ sampleHolder.timeUs = holder.timeUs;
+ sampleHolder.data.put(holder.data);
+ mCcSamplePool.releaseSample(holder);
+ return SampleSource.SAMPLE_READ;
+ } else {
+ return mVideoTrackIndex < 0 || mReachedEos.get(mVideoTrackIndex)
+ ? SampleSource.END_OF_STREAM
+ : SampleSource.NOTHING_READ;
+ }
+ }
+
+ int result = mSampleExtractor.readSample(track, sampleHolder);
+ switch (result) {
+ case SampleSource.END_OF_STREAM:
+ {
+ mReachedEos.set(track, true);
+ break;
+ }
+ case SampleSource.SAMPLE_READ:
+ {
+ if (mCea708TextTrackSelected
+ && track == mVideoTrackIndex
+ && sampleHolder.data != null) {
+ mCcParser.mayParseClosedCaption(sampleHolder.data, sampleHolder.timeUs);
+ }
+ break;
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void release() {
+ mSampleExtractor.release();
+ mVideoTrackIndex = -1;
+ mCea708TextTrackIndex = -1;
+ mCea708TextTrackSelected = false;
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleExtractor.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) {}
+
+ private abstract class CcParser {
+ // Interim buffer for reduce direct access to ByteBuffer which is expensive. Using
+ // relatively small buffer size in order to minimize memory footprint increase.
+ protected final byte[] mBuffer = new byte[1024];
+
+ abstract void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs);
+
+ protected int parseClosedCaption(ByteBuffer buffer, int offset, long presentationTimeUs) {
+ // For the details of user_data_type_structure, see ATSC A/53 Part 4 - Table 6.9.
+ int pos = offset;
+ if (pos + 2 >= buffer.position()) {
+ return offset;
+ }
+ boolean processCcDataFlag = (buffer.get(pos) & 64) != 0;
+ int ccCount = buffer.get(pos) & 0x1f;
+ pos += 2;
+ if (!processCcDataFlag || pos + 3 * ccCount >= buffer.position() || ccCount == 0) {
+ return offset;
+ }
+ SampleHolder holder = mCcSamplePool.acquireSample(CC_BUFFER_SIZE_IN_BYTES);
+ for (int i = 0; i < 3 * ccCount; i++) {
+ holder.data.put(buffer.get(pos++));
+ }
+ holder.timeUs = presentationTimeUs;
+ mPendingCcSamples.add(holder);
+ return pos;
+ }
+ }
+
+ private class Mpeg2CcParser extends CcParser {
+ private static final int PATTERN_LENGTH = 9;
+
+ @Override
+ public void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs) {
+ int totalSize = buffer.position();
+ // Reading the frame in bulk to reduce the overhead from ByteBuffer.get() with
+ // overlapping to handle the case that the pattern exists in the boundary.
+ for (int i = 0; i < totalSize; i += mBuffer.length - PATTERN_LENGTH) {
+ buffer.position(i);
+ int size = Math.min(totalSize - i, mBuffer.length);
+ buffer.get(mBuffer, 0, size);
+ int j = 0;
+ while (j < size - PATTERN_LENGTH) {
+ // Find the start prefix code of private user data.
+ if (mBuffer[j] == 0
+ && mBuffer[j + 1] == 0
+ && mBuffer[j + 2] == 1
+ && (mBuffer[j + 3] & 0xff) == 0xb2) {
+ // ATSC closed caption data embedded in MPEG2VIDEO stream has 'GA94' user
+ // identifier and user data type code 3.
+ if (mBuffer[j + 4] == 'G'
+ && mBuffer[j + 5] == 'A'
+ && mBuffer[j + 6] == '9'
+ && mBuffer[j + 7] == '4'
+ && mBuffer[j + 8] == 3) {
+ j =
+ parseClosedCaption(
+ buffer,
+ i + j + PATTERN_LENGTH,
+ presentationTimeUs)
+ - i;
+ } else {
+ j += PATTERN_LENGTH;
+ }
+ } else {
+ ++j;
+ }
+ }
+ }
+ buffer.position(totalSize);
+ }
+ }
+
+ private class H264CcParser extends CcParser {
+ private static final int PATTERN_LENGTH = 14;
+
+ @Override
+ public void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs) {
+ int totalSize = buffer.position();
+ // Reading the frame in bulk to reduce the overhead from ByteBuffer.get() with
+ // overlapping to handle the case that the pattern exists in the boundary.
+ for (int i = 0; i < totalSize; i += mBuffer.length - PATTERN_LENGTH) {
+ buffer.position(i);
+ int size = Math.min(totalSize - i, mBuffer.length);
+ buffer.get(mBuffer, 0, size);
+ int j = 0;
+ while (j < size - PATTERN_LENGTH) {
+ // Find the start prefix code of a NAL Unit.
+ if (mBuffer[j] == 0 && mBuffer[j + 1] == 0 && mBuffer[j + 2] == 1) {
+ int nalType = mBuffer[j + 3] & 0x1f;
+ int payloadType = mBuffer[j + 4] & 0xff;
+
+ // ATSC closed caption data embedded in H264 private user data has NAL type
+ // 6, payload type 4, and 'GA94' user identifier for ATSC.
+ if (nalType == 6
+ && payloadType == 4
+ && mBuffer[j + 9] == 'G'
+ && mBuffer[j + 10] == 'A'
+ && mBuffer[j + 11] == '9'
+ && mBuffer[j + 12] == '4') {
+ j =
+ parseClosedCaption(
+ buffer,
+ i + j + PATTERN_LENGTH,
+ presentationTimeUs)
+ - i;
+ } else {
+ j += 7;
+ }
+ } else {
+ ++j;
+ }
+ }
+ }
+ buffer.position(totalSize);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java
new file mode 100644
index 00000000..3b5d1011
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java
@@ -0,0 +1,195 @@
+/*
+ * 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.exoplayer;
+
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.SampleSource.SampleSourceReader;
+import com.google.android.exoplayer.util.Assertions;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/** {@link SampleSource} that extracts sample data using a {@link SampleExtractor}. */
+public final class MpegTsSampleSource implements SampleSource, SampleSourceReader {
+
+ private static final int TRACK_STATE_DISABLED = 0;
+ private static final int TRACK_STATE_ENABLED = 1;
+ private static final int TRACK_STATE_FORMAT_SENT = 2;
+
+ private final SampleExtractor mSampleExtractor;
+ private final List<Integer> mTrackStates = new ArrayList<>();
+ private final List<Boolean> mPendingDiscontinuities = new ArrayList<>();
+
+ private boolean mPrepared;
+ private IOException mPreparationError;
+ private int mRemainingReleaseCount;
+
+ private long mLastSeekPositionUs;
+ private long mPendingSeekPositionUs;
+
+ /**
+ * Creates a new sample source that extracts samples using {@code mSampleExtractor}.
+ *
+ * @param sampleExtractor a sample extractor for accessing media samples
+ */
+ public MpegTsSampleSource(SampleExtractor sampleExtractor) {
+ mSampleExtractor = Assertions.checkNotNull(sampleExtractor);
+ }
+
+ @Override
+ public SampleSourceReader register() {
+ mRemainingReleaseCount++;
+ return this;
+ }
+
+ @Override
+ public boolean prepare(long positionUs) {
+ if (!mPrepared) {
+ if (mPreparationError != null) {
+ return false;
+ }
+ try {
+ if (mSampleExtractor.prepare()) {
+ int trackCount = mSampleExtractor.getTrackFormats().size();
+ mTrackStates.clear();
+ mPendingDiscontinuities.clear();
+ for (int i = 0; i < trackCount; ++i) {
+ mTrackStates.add(i, TRACK_STATE_DISABLED);
+ mPendingDiscontinuities.add(i, false);
+ }
+ mPrepared = true;
+ } else {
+ return false;
+ }
+ } catch (IOException e) {
+ mPreparationError = e;
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public int getTrackCount() {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getTrackFormats().size();
+ }
+
+ @Override
+ public MediaFormat getFormat(int track) {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getTrackFormats().get(track);
+ }
+
+ @Override
+ public void enable(int track, long positionUs) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) == TRACK_STATE_DISABLED);
+ mTrackStates.set(track, TRACK_STATE_ENABLED);
+ mSampleExtractor.selectTrack(track);
+ seekToUsInternal(positionUs, positionUs != 0);
+ }
+
+ @Override
+ public void disable(int track) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) != TRACK_STATE_DISABLED);
+ mSampleExtractor.deselectTrack(track);
+ mPendingDiscontinuities.set(track, false);
+ mTrackStates.set(track, TRACK_STATE_DISABLED);
+ }
+
+ @Override
+ public boolean continueBuffering(int track, long positionUs) {
+ return mSampleExtractor.continueBuffering(positionUs);
+ }
+
+ @Override
+ public long readDiscontinuity(int track) {
+ if (mPendingDiscontinuities.get(track)) {
+ mPendingDiscontinuities.set(track, false);
+ return mLastSeekPositionUs;
+ }
+ return NO_DISCONTINUITY;
+ }
+
+ @Override
+ public int readData(
+ int track, long positionUs, MediaFormatHolder formatHolder, SampleHolder sampleHolder) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) != TRACK_STATE_DISABLED);
+ if (mPendingDiscontinuities.get(track)) {
+ return NOTHING_READ;
+ }
+ if (mTrackStates.get(track) != TRACK_STATE_FORMAT_SENT) {
+ mSampleExtractor.getTrackMediaFormat(track, formatHolder);
+ mTrackStates.set(track, TRACK_STATE_FORMAT_SENT);
+ return FORMAT_READ;
+ }
+
+ mPendingSeekPositionUs = C.UNKNOWN_TIME_US;
+ return mSampleExtractor.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mPreparationError != null) {
+ throw mPreparationError;
+ }
+ if (mSampleExtractor != null) {
+ mSampleExtractor.maybeThrowError();
+ }
+ }
+
+ @Override
+ public void seekToUs(long positionUs) {
+ Assertions.checkState(mPrepared);
+ seekToUsInternal(positionUs, false);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getBufferedPositionUs();
+ }
+
+ @Override
+ public void release() {
+ Assertions.checkState(mRemainingReleaseCount > 0);
+ if (--mRemainingReleaseCount == 0) {
+ mSampleExtractor.release();
+ }
+ }
+
+ private void seekToUsInternal(long positionUs, boolean force) {
+ // Unless forced, avoid duplicate calls to the underlying extractor's seek method
+ // in the case that there have been no interleaving calls to readSample.
+ if (force || mPendingSeekPositionUs != positionUs) {
+ mLastSeekPositionUs = positionUs;
+ mPendingSeekPositionUs = positionUs;
+ mSampleExtractor.seekTo(positionUs);
+ for (int i = 0; i < mTrackStates.size(); ++i) {
+ if (mTrackStates.get(i) != TRACK_STATE_DISABLED) {
+ mPendingDiscontinuities.set(i, true);
+ }
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java
new file mode 100644
index 00000000..b136e235
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2017 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.exoplayer;
+
+import android.content.Context;
+import android.media.MediaCodec;
+import android.os.Handler;
+import android.util.Log;
+import com.android.tv.tuner.TunerFeatures;
+import com.google.android.exoplayer.DecoderInfo;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.MediaCodecUtil;
+import com.google.android.exoplayer.MediaCodecVideoTrackRenderer;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.MediaSoftwareCodecUtil;
+import com.google.android.exoplayer.SampleSource;
+import java.lang.reflect.Field;
+
+/** MPEG-2 TS video track renderer */
+public class MpegTsVideoTrackRenderer extends MediaCodecVideoTrackRenderer {
+ private static final String TAG = "MpegTsVideoTrackRender";
+
+ private static final int VIDEO_PLAYBACK_DEADLINE_IN_MS = 5000;
+ // If DROPPED_FRAMES_NOTIFICATION_THRESHOLD frames are consecutively dropped, it'll be notified.
+ private static final int DROPPED_FRAMES_NOTIFICATION_THRESHOLD = 10;
+ private static final int MIN_HD_HEIGHT = 720;
+ private static final String MIMETYPE_MPEG2 = "video/mpeg2";
+ private static Field sRenderedFirstFrameField;
+
+ private final boolean mIsSwCodecEnabled;
+ private boolean mCodecIsSwPreferred;
+ private boolean mSetRenderedFirstFrame;
+
+ static {
+ // Remove the reflection below once b/31223646 is resolved.
+ try {
+ sRenderedFirstFrameField =
+ MediaCodecVideoTrackRenderer.class.getDeclaredField("renderedFirstFrame");
+ sRenderedFirstFrameField.setAccessible(true);
+ } catch (NoSuchFieldException e) {
+ // Null-checking for {@code sRenderedFirstFrameField} will do the error handling.
+ }
+ }
+
+ public MpegTsVideoTrackRenderer(
+ Context context,
+ SampleSource source,
+ Handler handler,
+ MediaCodecVideoTrackRenderer.EventListener listener) {
+ super(
+ context,
+ source,
+ MediaCodecSelector.DEFAULT,
+ MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT,
+ VIDEO_PLAYBACK_DEADLINE_IN_MS,
+ handler,
+ listener,
+ DROPPED_FRAMES_NOTIFICATION_THRESHOLD);
+ mIsSwCodecEnabled = TunerFeatures.USE_SW_CODEC_FOR_SD.isEnabled(context);
+ }
+
+ @Override
+ protected DecoderInfo getDecoderInfo(
+ MediaCodecSelector codecSelector, String mimeType, boolean requiresSecureDecoder)
+ throws MediaCodecUtil.DecoderQueryException {
+ try {
+ if (mIsSwCodecEnabled && mCodecIsSwPreferred) {
+ DecoderInfo swCodec =
+ MediaSoftwareCodecUtil.getSoftwareDecoderInfo(
+ mimeType, requiresSecureDecoder);
+ if (swCodec != null) {
+ return swCodec;
+ }
+ }
+ } catch (MediaSoftwareCodecUtil.DecoderQueryException e) {
+ }
+ return super.getDecoderInfo(codecSelector, mimeType, requiresSecureDecoder);
+ }
+
+ @Override
+ protected void onInputFormatChanged(MediaFormatHolder holder) throws ExoPlaybackException {
+ mCodecIsSwPreferred =
+ MIMETYPE_MPEG2.equalsIgnoreCase(holder.format.mimeType)
+ && holder.format.height < MIN_HD_HEIGHT;
+ super.onInputFormatChanged(holder);
+ }
+
+ @Override
+ protected void onDiscontinuity(long positionUs) throws ExoPlaybackException {
+ super.onDiscontinuity(positionUs);
+ // Disabling pre-rendering of the first frame in order to avoid a frozen picture when
+ // starting the playback. We do this only once, when the renderer is enabled at first, since
+ // we need to pre-render the frame in advance when we do trickplay backed by seeking.
+ if (!mSetRenderedFirstFrame) {
+ setRenderedFirstFrame(true);
+ mSetRenderedFirstFrame = true;
+ }
+ }
+
+ private void setRenderedFirstFrame(boolean renderedFirstFrame) {
+ if (sRenderedFirstFrameField != null) {
+ try {
+ sRenderedFirstFrameField.setBoolean(this, renderedFirstFrame);
+ } catch (IllegalAccessException e) {
+ Log.w(
+ TAG,
+ "renderedFirstFrame is not accessible. Playback may start with a frozen"
+ + " picture.");
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/SampleExtractor.java b/tuner/src/com/android/tv/tuner/exoplayer/SampleExtractor.java
new file mode 100644
index 00000000..256aea92
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/SampleExtractor.java
@@ -0,0 +1,131 @@
+/*
+ * 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.exoplayer;
+
+import android.os.Handler;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Extractor for reading track metadata and samples stored in tracks.
+ *
+ * <p>Call {@link #prepare} until it returns {@code true}, then access track metadata via {@link
+ * #getTrackFormats} and {@link #getTrackMediaFormat}.
+ *
+ * <p>Pass indices of tracks to read from to {@link #selectTrack}. A track can later be deselected
+ * by calling {@link #deselectTrack}. It is safe to select/deselect tracks after reading sample data
+ * or seeking. Initially, all tracks are deselected.
+ *
+ * <p>Call {@link #release()} when the extractor is no longer needed to free resources.
+ */
+public interface SampleExtractor {
+
+ /**
+ * If the extractor is currently having difficulty preparing or loading samples, then this
+ * method throws the underlying error. Otherwise does nothing.
+ *
+ * @throws IOException The underlying error.
+ */
+ void maybeThrowError() throws IOException;
+
+ /**
+ * Prepares the extractor for reading track metadata and samples.
+ *
+ * @return whether the source is ready; if {@code false}, this method must be called again.
+ * @throws IOException thrown if the source can't be read
+ */
+ boolean prepare() throws IOException;
+
+ /** Returns track information about all tracks that can be selected. */
+ List<MediaFormat> getTrackFormats();
+
+ /** Selects the track at {@code index} for reading sample data. */
+ void selectTrack(int index);
+
+ /** Deselects the track at {@code index}, so no more samples will be read from that track. */
+ void deselectTrack(int index);
+
+ /**
+ * Returns an estimate of the position up to which data is buffered.
+ *
+ * <p>This method should not be called until after the extractor has been successfully prepared.
+ *
+ * @return an estimate of the absolute position in microseconds up to which data is buffered, or
+ * {@link TrackRenderer#END_OF_TRACK_US} if data is buffered to the end of the stream, or
+ * {@link TrackRenderer#UNKNOWN_TIME_US} if no estimate is available.
+ */
+ long getBufferedPositionUs();
+
+ /**
+ * Seeks to the specified time in microseconds.
+ *
+ * <p>This method should not be called until after the extractor has been successfully prepared.
+ *
+ * @param positionUs the seek position in microseconds
+ */
+ void seekTo(long positionUs);
+
+ /** Stores the {@link MediaFormat} of {@code track}. */
+ void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder);
+
+ /**
+ * Reads the next sample in the track at index {@code track} into {@code sampleHolder},
+ * returning {@link SampleSource#SAMPLE_READ} if it is available.
+ *
+ * <p>Advances to the next sample if a sample was read.
+ *
+ * @param track the index of the track from which to read a sample
+ * @param sampleHolder the holder for read sample data, if {@link SampleSource#SAMPLE_READ} is
+ * returned
+ * @return {@link SampleSource#SAMPLE_READ} if a sample was read into {@code sampleHolder}, or
+ * {@link SampleSource#END_OF_STREAM} if the last samples in all tracks have been read, or
+ * {@link SampleSource#NOTHING_READ} if the sample cannot be read immediately as it is not
+ * loaded.
+ */
+ int readSample(int track, SampleHolder sampleHolder);
+
+ /** Releases resources associated with this extractor. */
+ void release();
+
+ /** Indicates to the source that it should still be buffering data. */
+ boolean continueBuffering(long positionUs);
+
+ /**
+ * Sets OnCompletionListener for notifying the completion of SampleExtractor.
+ *
+ * @param listener the OnCompletionListener
+ * @param handler the {@link Handler} for {@link Handler#post(Runnable)} of OnCompletionListener
+ */
+ void setOnCompletionListener(OnCompletionListener listener, Handler handler);
+
+ /** The listener for SampleExtractor being completed. */
+ interface OnCompletionListener {
+
+ /**
+ * Called when sample extraction is completed.
+ *
+ * @param result {@code true} when the extractor is finished without an error, {@code false}
+ * otherwise (storage error, weak signal, being reached at EoS prematurely, etc.)
+ * @param lastExtractedPositionUs the last extracted position when extractor is completed
+ */
+ void onCompletion(boolean result, long lastExtractedPositionUs);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java
new file mode 100644
index 00000000..13eabc3a
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java
@@ -0,0 +1,94 @@
+/*
+ * 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.exoplayer.audio;
+
+import android.os.SystemClock;
+import com.android.tv.common.SoftPreconditions;
+
+/**
+ * Copy of {@link com.google.android.exoplayer.MediaClock}.
+ *
+ * <p>A simple clock for tracking the progression of media time. The clock can be started, stopped
+ * and its time can be set and retrieved. When started, this clock is based on {@link
+ * SystemClock#elapsedRealtime()}.
+ */
+/* package */ class AudioClock {
+ private boolean mStarted;
+
+ /** The media time when the clock was last set or stopped. */
+ private long mPositionUs;
+
+ /**
+ * The difference between {@link SystemClock#elapsedRealtime()} and {@link #mPositionUs} when
+ * the clock was last set or mStarted.
+ */
+ private long mDeltaUs;
+
+ private float mPlaybackSpeed = 1.0f;
+ private long mDeltaUpdatedTimeUs;
+
+ /** Starts the clock. Does nothing if the clock is already started. */
+ public void start() {
+ if (!mStarted) {
+ mStarted = true;
+ mDeltaUs = elapsedRealtimeMinus(mPositionUs);
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ }
+ }
+
+ /** Stops the clock. Does nothing if the clock is already stopped. */
+ public void stop() {
+ if (mStarted) {
+ mPositionUs = elapsedRealtimeMinus(mDeltaUs);
+ mStarted = false;
+ }
+ }
+
+ /** @param timeUs The position to set in microseconds. */
+ public void setPositionUs(long timeUs) {
+ this.mPositionUs = timeUs;
+ mDeltaUs = elapsedRealtimeMinus(timeUs);
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ }
+
+ /** @return The current position in microseconds. */
+ public long getPositionUs() {
+ if (!mStarted) {
+ return mPositionUs;
+ }
+ if (mPlaybackSpeed != 1.0f) {
+ long elapsedTimeFromPlaybackSpeedChanged =
+ SystemClock.elapsedRealtime() * 1000 - mDeltaUpdatedTimeUs;
+ return elapsedRealtimeMinus(mDeltaUs)
+ + (long) ((mPlaybackSpeed - 1.0f) * elapsedTimeFromPlaybackSpeedChanged);
+ } else {
+ return elapsedRealtimeMinus(mDeltaUs);
+ }
+ }
+
+ /** Sets playback speed. {@code speed} should be positive. */
+ public void setPlaybackSpeed(float speed) {
+ SoftPreconditions.checkState(speed > 0);
+ mDeltaUs = elapsedRealtimeMinus(getPositionUs());
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ mPlaybackSpeed = speed;
+ }
+
+ private long elapsedRealtimeMinus(long toSubtractUs) {
+ return SystemClock.elapsedRealtime() * 1000 - toSubtractUs;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java
new file mode 100644
index 00000000..64fe1344
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2017 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.exoplayer.audio;
+
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import java.nio.ByteBuffer;
+
+/** A base class for audio decoders. */
+public abstract class AudioDecoder {
+
+ /**
+ * Decodes an audio sample.
+ *
+ * @param sampleHolder a holder that contains the sample data and corresponding metadata
+ */
+ public abstract void decode(SampleHolder sampleHolder);
+
+ /** Returns a decoded sample from decoder. */
+ public abstract ByteBuffer getDecodedSample();
+
+ /** Returns the presentation time for the decoded sample. */
+ public abstract long getDecodedTimeUs();
+
+ /**
+ * Clear previous decode state if any. Prepares to decode samples of the specified encoding.
+ * This method should be called before using decode.
+ *
+ * @param mimeType audio encoding
+ */
+ public abstract void resetDecoderState(String mimeType);
+
+ /** Releases all the resource. */
+ public abstract void release();
+
+ /**
+ * Init decoder if needed.
+ *
+ * @param format the format used to initialize decoder
+ */
+ public void maybeInitDecoder(MediaFormat format) throws ExoPlaybackException {
+ // Do nothing.
+ }
+
+ /** Returns input buffer that will be used in decoder. */
+ public ByteBuffer getInputBuffer() {
+ return null;
+ }
+
+ /** Returns the output format. */
+ public android.media.MediaFormat getOutputFormat() {
+ return null;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java
new file mode 100644
index 00000000..28389017
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java
@@ -0,0 +1,140 @@
+/*
+ * 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.exoplayer.audio;
+
+import android.os.SystemClock;
+import android.util.Log;
+import android.util.Pair;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Monitors the rendering position of {@link AudioTrack}. */
+public class AudioTrackMonitor {
+ private static final String TAG = "AudioTrackMonitor";
+ private static final boolean DEBUG = false;
+
+ // For fetched audio samples
+ private final ArrayList<Pair<Long, Integer>> mPtsList = new ArrayList<>();
+ private final Set<Integer> mSampleSize = new HashSet<>();
+ private final Set<Integer> mCurSampleSize = new HashSet<>();
+ private final Set<Integer> mHeader = new HashSet<>();
+
+ private long mExpireMs;
+ private long mDuration;
+ private long mSampleCount;
+ private long mTotalCount;
+ private long mStartMs;
+
+ private boolean mIsMp2;
+
+ private void flush() {
+ mExpireMs += mDuration;
+ mSampleCount = 0;
+ mCurSampleSize.clear();
+ mPtsList.clear();
+ }
+
+ /**
+ * Resets and initializes {@link AudioTrackMonitor}.
+ *
+ * @param duration the frequency of monitoring in milliseconds
+ */
+ public void reset(long duration) {
+ mExpireMs = SystemClock.elapsedRealtime();
+ mDuration = duration;
+ mTotalCount = 0;
+ mStartMs = 0;
+ mSampleSize.clear();
+ mHeader.clear();
+ flush();
+ }
+
+ public void setEncoding(String mime) {
+ mIsMp2 = MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mime);
+ }
+
+ /**
+ * Adds an audio sample information for monitoring.
+ *
+ * @param pts the presentation timestamp of the sample
+ * @param sampleSize the size in bytes of the sample
+ * @param header the bitrate &amp; sampling information header of the sample
+ */
+ public void addPts(long pts, int sampleSize, int header) {
+ mTotalCount++;
+ mSampleCount++;
+ mSampleSize.add(sampleSize);
+ mHeader.add(header);
+ mCurSampleSize.add(sampleSize);
+ if (mTotalCount == 1) {
+ mStartMs = SystemClock.elapsedRealtime();
+ }
+ if (mPtsList.isEmpty() || mPtsList.get(mPtsList.size() - 1).first != pts) {
+ mPtsList.add(Pair.create(pts, 1));
+ return;
+ }
+ Pair<Long, Integer> pair = mPtsList.get(mPtsList.size() - 1);
+ mPtsList.set(mPtsList.size() - 1, Pair.create(pair.first, pair.second + 1));
+ }
+
+ /**
+ * Logs if interested events are present.
+ *
+ * <p>Periodic logging is not enabled in release mode in order to avoid verbose logging.
+ */
+ public void maybeLog() {
+ long now = SystemClock.elapsedRealtime();
+ if (mExpireMs != 0 && now >= mExpireMs) {
+ if (DEBUG) {
+ long unitDuration =
+ mIsMp2
+ ? MpegTsDefaultAudioTrackRenderer.MP2_SAMPLE_DURATION_US
+ : MpegTsDefaultAudioTrackRenderer.AC3_SAMPLE_DURATION_US;
+ long sampleDuration = (mTotalCount - 1) * unitDuration / 1000;
+ long totalDuration = now - mStartMs;
+ StringBuilder ptsBuilder = new StringBuilder();
+ ptsBuilder
+ .append("PTS received ")
+ .append(mSampleCount)
+ .append(", ")
+ .append(totalDuration - sampleDuration)
+ .append(' ');
+
+ for (Pair<Long, Integer> pair : mPtsList) {
+ ptsBuilder
+ .append('[')
+ .append(pair.first)
+ .append(':')
+ .append(pair.second)
+ .append("], ");
+ }
+ Log.d(TAG, ptsBuilder.toString());
+ }
+ if (DEBUG || mCurSampleSize.size() > 1) {
+ Log.d(
+ TAG,
+ "PTS received sample size: "
+ + String.valueOf(mSampleSize)
+ + mCurSampleSize
+ + mHeader);
+ }
+ flush();
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java
new file mode 100644
index 00000000..7446c923
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java
@@ -0,0 +1,174 @@
+/*
+ * 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.exoplayer.audio;
+
+import android.media.MediaFormat;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.audio.AudioTrack;
+import java.nio.ByteBuffer;
+
+/**
+ * {@link AudioTrack} wrapper class for trickplay operations including FF/RW. FF/RW trickplay
+ * operations do not need framework {@link AudioTrack}. This wrapper class will do nothing in
+ * disabled status for those operations.
+ */
+public class AudioTrackWrapper {
+ private static final int PCM16_FRAME_BYTES = 2;
+ private static final int AC3_FRAMES_IN_ONE_SAMPLE = 1536;
+ private static final int BUFFERED_SAMPLES_IN_AUDIOTRACK =
+ MpegTsDefaultAudioTrackRenderer.BUFFERED_SAMPLES_IN_AUDIOTRACK;
+ private final AudioTrack mAudioTrack = new AudioTrack();
+ private int mAudioSessionID;
+ private boolean mIsEnabled;
+
+ AudioTrackWrapper() {
+ mIsEnabled = true;
+ }
+
+ public void resetSessionId() {
+ mAudioSessionID = AudioTrack.SESSION_ID_NOT_SET;
+ }
+
+ public boolean isInitialized() {
+ return mIsEnabled && mAudioTrack.isInitialized();
+ }
+
+ public void restart() {
+ if (mAudioTrack.isInitialized()) {
+ mAudioTrack.release();
+ }
+ mIsEnabled = true;
+ resetSessionId();
+ }
+
+ public void release() {
+ if (mAudioSessionID != AudioTrack.SESSION_ID_NOT_SET) {
+ mAudioTrack.release();
+ }
+ }
+
+ public void initialize() throws AudioTrack.InitializationException {
+ if (!mIsEnabled) {
+ return;
+ }
+ if (mAudioSessionID != AudioTrack.SESSION_ID_NOT_SET) {
+ mAudioTrack.initialize(mAudioSessionID);
+ } else {
+ mAudioSessionID = mAudioTrack.initialize();
+ }
+ }
+
+ public void reset() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.reset();
+ }
+
+ public boolean isEnded() {
+ return !mIsEnabled || !mAudioTrack.hasPendingData();
+ }
+
+ public boolean isReady() {
+ // In the case of not playing actual audio data, Audio track is always ready.
+ return !mIsEnabled || mAudioTrack.hasPendingData();
+ }
+
+ public void play() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.play();
+ }
+
+ public void pause() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.pause();
+ }
+
+ public void setVolume(float volume) {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.setVolume(volume);
+ }
+
+ public void reconfigure(MediaFormat format, int audioBufferSize) {
+ if (!mIsEnabled || format == null) {
+ return;
+ }
+ String mimeType = format.getString(MediaFormat.KEY_MIME);
+ int channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
+ int sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE);
+ int pcmEncoding;
+ try {
+ pcmEncoding = format.getInteger(MediaFormat.KEY_PCM_ENCODING);
+ } catch (Exception e) {
+ pcmEncoding = C.ENCODING_PCM_16BIT;
+ }
+ // TODO: Handle non-AC3.
+ if (MediaFormat.MIMETYPE_AUDIO_AC3.equalsIgnoreCase(mimeType) && channelCount != 2) {
+ // Workarounds b/25955476.
+ // Since all devices and platforms does not support passthrough for non-stereo AC3,
+ // It is safe to fake non-stereo AC3 as AC3 stereo which is default passthrough mode.
+ // In other words, the channel count should be always 2.
+ channelCount = 2;
+ }
+ if (MediaFormat.MIMETYPE_AUDIO_RAW.equalsIgnoreCase(mimeType)) {
+ audioBufferSize =
+ channelCount
+ * PCM16_FRAME_BYTES
+ * AC3_FRAMES_IN_ONE_SAMPLE
+ * BUFFERED_SAMPLES_IN_AUDIOTRACK;
+ }
+ mAudioTrack.configure(mimeType, channelCount, sampleRate, pcmEncoding, audioBufferSize);
+ }
+
+ public void handleDiscontinuity() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.handleDiscontinuity();
+ }
+
+ public int handleBuffer(ByteBuffer buffer, int offset, int size, long presentationTimeUs)
+ throws AudioTrack.WriteException {
+ if (!mIsEnabled) {
+ return AudioTrack.RESULT_BUFFER_CONSUMED;
+ }
+ return mAudioTrack.handleBuffer(buffer, offset, size, presentationTimeUs);
+ }
+
+ public void setStatus(boolean enable) {
+ if (enable == mIsEnabled) {
+ return;
+ }
+ mAudioTrack.reset();
+ mIsEnabled = enable;
+ }
+
+ public boolean isEnabled() {
+ return mIsEnabled;
+ }
+
+ // This should be used only in case of being enabled.
+ public long getCurrentPositionUs(boolean isEnded) {
+ return mAudioTrack.getCurrentPositionUs(isEnded);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java
new file mode 100644
index 00000000..80f91ebd
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright (C) 2017 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.exoplayer.audio;
+
+import android.media.MediaCodec;
+import android.util.Log;
+import com.google.android.exoplayer.CodecCounters;
+import com.google.android.exoplayer.DecoderInfo;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.MediaCodecUtil;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+
+/** A decoder to use MediaCodec for decoding audio stream. */
+public class MediaCodecAudioDecoder extends AudioDecoder {
+ private static final String TAG = "MediaCodecAudioDecoder";
+
+ public static final int INDEX_INVALID = -1;
+
+ private final CodecCounters mCodecCounters;
+ private final MediaCodecSelector mSelector;
+
+ private MediaCodec mCodec;
+ private MediaCodec.BufferInfo mOutputBufferInfo;
+ private ByteBuffer mMediaCodecOutputBuffer;
+ private ArrayList<Long> mDecodeOnlyPresentationTimestamps;
+ private boolean mWaitingForFirstSyncFrame;
+ private boolean mIsNewIndex;
+ private int mInputIndex;
+ private int mOutputIndex;
+
+ /** Creates a MediaCodec based audio decoder. */
+ public MediaCodecAudioDecoder(MediaCodecSelector selector) {
+ mSelector = selector;
+ mOutputBufferInfo = new MediaCodec.BufferInfo();
+ mCodecCounters = new CodecCounters();
+ mDecodeOnlyPresentationTimestamps = new ArrayList<>();
+ }
+
+ /** Returns {@code true} if there is decoder for {@code mimeType}. */
+ public static boolean supportMimeType(MediaCodecSelector selector, String mimeType) {
+ if (selector == null) {
+ return false;
+ }
+ return getDecoderInfo(selector, mimeType) != null;
+ }
+
+ private static DecoderInfo getDecoderInfo(MediaCodecSelector selector, String mimeType) {
+ try {
+ return selector.getDecoderInfo(mimeType, false);
+ } catch (MediaCodecUtil.DecoderQueryException e) {
+ Log.e(TAG, "Select decoder error:" + e);
+ return null;
+ }
+ }
+
+ private boolean shouldInitCodec(MediaFormat format) {
+ return format != null && mCodec == null;
+ }
+
+ @Override
+ public void maybeInitDecoder(MediaFormat format) throws ExoPlaybackException {
+ if (!shouldInitCodec(format)) {
+ return;
+ }
+
+ String mimeType = format.mimeType;
+ DecoderInfo decoderInfo = getDecoderInfo(mSelector, mimeType);
+ if (decoderInfo == null) {
+ Log.i(TAG, "There is not decoder found for " + mimeType);
+ return;
+ }
+
+ String codecName = decoderInfo.name;
+ try {
+ mCodec = MediaCodec.createByCodecName(codecName);
+ mCodec.configure(format.getFrameworkMediaFormatV16(), null, null, 0);
+ mCodec.start();
+ } catch (Exception e) {
+ Log.e(TAG, "Failed when configure or start codec:" + e);
+ throw new ExoPlaybackException(e);
+ }
+ mInputIndex = INDEX_INVALID;
+ mOutputIndex = INDEX_INVALID;
+ mWaitingForFirstSyncFrame = true;
+ mCodecCounters.codecInitCount++;
+ }
+
+ @Override
+ public void resetDecoderState(String mimeType) {
+ if (mCodec == null) {
+ return;
+ }
+ mInputIndex = INDEX_INVALID;
+ mOutputIndex = INDEX_INVALID;
+ mDecodeOnlyPresentationTimestamps.clear();
+ mCodec.flush();
+ mWaitingForFirstSyncFrame = true;
+ }
+
+ @Override
+ public void release() {
+ if (mCodec != null) {
+ mDecodeOnlyPresentationTimestamps.clear();
+ mInputIndex = INDEX_INVALID;
+ mOutputIndex = INDEX_INVALID;
+ mCodecCounters.codecReleaseCount++;
+ try {
+ mCodec.stop();
+ } finally {
+ try {
+ mCodec.release();
+ } finally {
+ mCodec = null;
+ }
+ }
+ }
+ }
+
+ /** Returns the index of input buffer which is ready for using. */
+ public int getInputIndex() {
+ return mInputIndex;
+ }
+
+ @Override
+ public ByteBuffer getInputBuffer() {
+ if (mInputIndex < 0) {
+ mInputIndex = mCodec.dequeueInputBuffer(0);
+ if (mInputIndex < 0) {
+ return null;
+ }
+ return mCodec.getInputBuffer(mInputIndex);
+ }
+ return mCodec.getInputBuffer(mInputIndex);
+ }
+
+ @Override
+ public void decode(SampleHolder sampleHolder) {
+ if (mWaitingForFirstSyncFrame) {
+ if (!sampleHolder.isSyncFrame()) {
+ sampleHolder.clearData();
+ return;
+ }
+ mWaitingForFirstSyncFrame = false;
+ }
+ long presentationTimeUs = sampleHolder.timeUs;
+ if (sampleHolder.isDecodeOnly()) {
+ mDecodeOnlyPresentationTimestamps.add(presentationTimeUs);
+ }
+ mCodec.queueInputBuffer(mInputIndex, 0, sampleHolder.data.limit(), presentationTimeUs, 0);
+ mInputIndex = INDEX_INVALID;
+ mCodecCounters.inputBufferCount++;
+ }
+
+ private int getDecodeOnlyIndex(long presentationTimeUs) {
+ final int size = mDecodeOnlyPresentationTimestamps.size();
+ for (int i = 0; i < size; i++) {
+ if (mDecodeOnlyPresentationTimestamps.get(i).longValue() == presentationTimeUs) {
+ return i;
+ }
+ }
+ return INDEX_INVALID;
+ }
+
+ /** Returns the index of output buffer which is ready for using. */
+ public int getOutputIndex() {
+ if (mOutputIndex < 0) {
+ mOutputIndex = mCodec.dequeueOutputBuffer(mOutputBufferInfo, 0);
+ mIsNewIndex = true;
+ } else {
+ mIsNewIndex = false;
+ }
+ return mOutputIndex;
+ }
+
+ @Override
+ public android.media.MediaFormat getOutputFormat() {
+ return mCodec.getOutputFormat();
+ }
+
+ /** Returns {@code true} if the output is only for decoding but not for rendering. */
+ public boolean maybeDecodeOnlyIndex() {
+ int decodeOnlyIndex = getDecodeOnlyIndex(mOutputBufferInfo.presentationTimeUs);
+ if (decodeOnlyIndex != INDEX_INVALID) {
+ mCodec.releaseOutputBuffer(mOutputIndex, false);
+ mCodecCounters.skippedOutputBufferCount++;
+ mDecodeOnlyPresentationTimestamps.remove(decodeOnlyIndex);
+ mOutputIndex = INDEX_INVALID;
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public ByteBuffer getDecodedSample() {
+ if (maybeDecodeOnlyIndex() || mOutputIndex < 0) {
+ return null;
+ }
+ if (mIsNewIndex) {
+ mMediaCodecOutputBuffer = mCodec.getOutputBuffer(mOutputIndex);
+ }
+ return mMediaCodecOutputBuffer;
+ }
+
+ @Override
+ public long getDecodedTimeUs() {
+ return mOutputBufferInfo.presentationTimeUs;
+ }
+
+ /** Releases the output buffer after rendering. */
+ public void releaseOutputBuffer() {
+ mCodecCounters.renderedOutputBufferCount++;
+ mCodec.releaseOutputBuffer(mOutputIndex, false);
+ mOutputIndex = INDEX_INVALID;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java
new file mode 100644
index 00000000..944cfbcf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java
@@ -0,0 +1,739 @@
+/*
+ * 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.exoplayer.audio;
+
+import android.media.MediaCodec;
+import android.os.Build;
+import android.os.Handler;
+import android.os.SystemClock;
+import android.util.Log;
+import com.android.tv.tuner.tvinput.TunerDebug;
+import com.google.android.exoplayer.CodecCounters;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaClock;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.audio.AudioTrack;
+import com.google.android.exoplayer.util.Assertions;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+
+/**
+ * Decodes and renders DTV audio. Supports MediaCodec based decoding, passthrough playback and
+ * ffmpeg based software decoding (AC3, MP2).
+ */
+public class MpegTsDefaultAudioTrackRenderer extends TrackRenderer implements MediaClock {
+ public static final int MSG_SET_VOLUME = 10000;
+ public static final int MSG_SET_AUDIO_TRACK = MSG_SET_VOLUME + 1;
+ public static final int MSG_SET_PLAYBACK_SPEED = MSG_SET_VOLUME + 2;
+
+ // ATSC/53 allows sample rate to be only 48Khz.
+ // One AC3 sample has 1536 frames, and its duration is 32ms.
+ public static final long AC3_SAMPLE_DURATION_US = 32000;
+
+ // TODO: Check whether DVB broadcasting uses sample rate other than 48Khz.
+ // MPEG-1 audio Layer II and III has 1152 frames per sample.
+ // 1152 frames duration is 24ms when sample rate is 48Khz.
+ static final long MP2_SAMPLE_DURATION_US = 24000;
+
+ // This is around 150ms, 150ms is big enough not to under-run AudioTrack,
+ // and 150ms is also small enough to fill the buffer rapidly.
+ static int BUFFERED_SAMPLES_IN_AUDIOTRACK = 5;
+ public static final long INITIAL_AUDIO_BUFFERING_TIME_US =
+ BUFFERED_SAMPLES_IN_AUDIOTRACK * AC3_SAMPLE_DURATION_US;
+
+ private static final String TAG = "MpegTsDefaultAudioTrac";
+ private static final boolean DEBUG = false;
+
+ /**
+ * Interface definition for a callback to be notified of {@link
+ * com.google.android.exoplayer.audio.AudioTrack} error.
+ */
+ public interface EventListener {
+ void onAudioTrackInitializationError(AudioTrack.InitializationException e);
+
+ void onAudioTrackWriteError(AudioTrack.WriteException e);
+ }
+
+ private static final int DEFAULT_INPUT_BUFFER_SIZE = 16384 * 2;
+ private static final int DEFAULT_OUTPUT_BUFFER_SIZE = 1024 * 1024;
+ private static final int MONITOR_DURATION_MS = 1000;
+ private static final int AC3_HEADER_BITRATE_OFFSET = 4;
+ private static final int MP2_HEADER_BITRATE_OFFSET = 2;
+ private static final int MP2_HEADER_BITRATE_MASK = 0xfc;
+
+ // Keep this as static in order to prevent new framework AudioTrack creation
+ // while old AudioTrack is being released.
+ private static final AudioTrackWrapper AUDIO_TRACK = new AudioTrackWrapper();
+ private static final long KEEP_ALIVE_AFTER_EOS_DURATION_MS = 3000;
+
+ // Ignore AudioTrack backward movement if duration of movement is below the threshold.
+ private static final long BACKWARD_AUDIO_TRACK_MOVE_THRESHOLD_US = 3000;
+
+ // AudioTrack position cannot go ahead beyond this limit.
+ private static final long CURRENT_POSITION_FROM_PTS_LIMIT_US = 1000000;
+
+ // Since MediaCodec processing and AudioTrack playing add delay,
+ // PTS interpolated time should be delayed reasonably when AudioTrack is not used.
+ private static final long ESTIMATED_TRACK_RENDERING_DELAY_US = 500000;
+
+ private final MediaCodecSelector mSelector;
+
+ private final CodecCounters mCodecCounters;
+ private final SampleSource.SampleSourceReader mSource;
+ private final MediaFormatHolder mFormatHolder;
+ private final EventListener mEventListener;
+ private final Handler mEventHandler;
+ private final AudioTrackMonitor mMonitor;
+ private final AudioClock mAudioClock;
+ private final boolean mAc3Passthrough;
+ private final boolean mSoftwareDecoderAvailable;
+
+ private MediaFormat mFormat;
+ private SampleHolder mSampleHolder;
+ private String mDecodingMime;
+ private boolean mFormatConfigured;
+ private int mSampleSize;
+ private final ByteBuffer mOutputBuffer;
+ private AudioDecoder mAudioDecoder;
+ private boolean mOutputReady;
+ private int mTrackIndex;
+ private boolean mSourceStateReady;
+ private boolean mInputStreamEnded;
+ private boolean mOutputStreamEnded;
+ private long mEndOfStreamMs;
+ private long mCurrentPositionUs;
+ private int mPresentationCount;
+ private long mPresentationTimeUs;
+ private long mInterpolatedTimeUs;
+ private long mPreviousPositionUs;
+ private boolean mIsStopped;
+ private boolean mEnabled = true;
+ private boolean mIsMuted;
+ private ArrayList<Integer> mTracksIndex;
+ private boolean mUseFrameworkDecoder;
+
+ public MpegTsDefaultAudioTrackRenderer(
+ SampleSource source,
+ MediaCodecSelector selector,
+ Handler eventHandler,
+ EventListener listener,
+ boolean hasSoftwareAudioDecoder,
+ boolean usePassthrough) {
+ mSource = source.register();
+ mSelector = selector;
+ mEventHandler = eventHandler;
+ mEventListener = listener;
+ mTrackIndex = -1;
+ mOutputBuffer = ByteBuffer.allocate(DEFAULT_OUTPUT_BUFFER_SIZE);
+ mFormatHolder = new MediaFormatHolder();
+ AUDIO_TRACK.restart();
+ mCodecCounters = new CodecCounters();
+ mMonitor = new AudioTrackMonitor();
+ mAudioClock = new AudioClock();
+ mTracksIndex = new ArrayList<>();
+ mAc3Passthrough = usePassthrough;
+ // TODO reimplement ffmpeg decoder check for google3
+ mSoftwareDecoderAvailable = false;
+ }
+
+ @Override
+ protected MediaClock getMediaClock() {
+ return this;
+ }
+
+ private boolean handlesMimeType(String mimeType) {
+ return mimeType.equals(MimeTypes.AUDIO_AC3)
+ || mimeType.equals(MimeTypes.AUDIO_E_AC3)
+ || mimeType.equals(MimeTypes.AUDIO_MPEG_L2)
+ || MediaCodecAudioDecoder.supportMimeType(mSelector, mimeType);
+ }
+
+ @Override
+ protected boolean doPrepare(long positionUs) throws ExoPlaybackException {
+ boolean sourcePrepared = mSource.prepare(positionUs);
+ if (!sourcePrepared) {
+ return false;
+ }
+ for (int i = 0; i < mSource.getTrackCount(); i++) {
+ String mimeType = mSource.getFormat(i).mimeType;
+ if (MimeTypes.isAudio(mimeType) && handlesMimeType(mimeType)) {
+ if (mTrackIndex < 0) {
+ mTrackIndex = i;
+ }
+ mTracksIndex.add(i);
+ }
+ }
+
+ // TODO: Check this case. Source does not have the proper mime type.
+ return true;
+ }
+
+ @Override
+ protected int getTrackCount() {
+ return mTracksIndex.size();
+ }
+
+ @Override
+ protected MediaFormat getFormat(int track) {
+ Assertions.checkArgument(track >= 0 && track < mTracksIndex.size());
+ return mSource.getFormat(mTracksIndex.get(track));
+ }
+
+ @Override
+ protected void onEnabled(int track, long positionUs, boolean joining) {
+ Assertions.checkArgument(track >= 0 && track < mTracksIndex.size());
+ mTrackIndex = mTracksIndex.get(track);
+ mSource.enable(mTrackIndex, positionUs);
+ seekToInternal(positionUs);
+ }
+
+ @Override
+ protected void onDisabled() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ AUDIO_TRACK.resetSessionId();
+ }
+ clearDecodeState();
+ mFormat = null;
+ mSource.disable(mTrackIndex);
+ }
+
+ @Override
+ protected void onReleased() {
+ releaseDecoder();
+ AUDIO_TRACK.release();
+ mSource.release();
+ }
+
+ @Override
+ protected boolean isEnded() {
+ return mOutputStreamEnded && AUDIO_TRACK.isEnded();
+ }
+
+ @Override
+ protected boolean isReady() {
+ return AUDIO_TRACK.isReady() || (mFormat != null && (mSourceStateReady || mOutputReady));
+ }
+
+ private void seekToInternal(long positionUs) {
+ mMonitor.reset(MONITOR_DURATION_MS);
+ mSourceStateReady = false;
+ mInputStreamEnded = false;
+ mOutputStreamEnded = false;
+ mPresentationTimeUs = positionUs;
+ mPresentationCount = 0;
+ mPreviousPositionUs = 0;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ mInterpolatedTimeUs = Long.MIN_VALUE;
+ mAudioClock.setPositionUs(positionUs);
+ }
+
+ @Override
+ protected void seekTo(long positionUs) {
+ mSource.seekToUs(positionUs);
+ AUDIO_TRACK.reset();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ // resetSessionId() will create a new framework AudioTrack instead of reusing old one.
+ AUDIO_TRACK.resetSessionId();
+ }
+ seekToInternal(positionUs);
+ clearDecodeState();
+ }
+
+ @Override
+ protected void onStarted() {
+ AUDIO_TRACK.play();
+ mAudioClock.start();
+ mIsStopped = false;
+ }
+
+ @Override
+ protected void onStopped() {
+ AUDIO_TRACK.pause();
+ mAudioClock.stop();
+ mIsStopped = true;
+ }
+
+ @Override
+ protected void maybeThrowError() throws ExoPlaybackException {
+ try {
+ mSource.maybeThrowError();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ @Override
+ protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
+ mMonitor.maybeLog();
+ try {
+ if (mEndOfStreamMs != 0) {
+ // Ensure playback stops, after EoS was notified.
+ // Sometimes MediaCodecTrackRenderer does not fetch EoS timely
+ // after EoS was notified here long before.
+ long diff = SystemClock.elapsedRealtime() - mEndOfStreamMs;
+ if (diff >= KEEP_ALIVE_AFTER_EOS_DURATION_MS && !mIsStopped) {
+ throw new ExoPlaybackException("Much time has elapsed after EoS");
+ }
+ }
+ boolean continueBuffering = mSource.continueBuffering(mTrackIndex, positionUs);
+ if (mSourceStateReady != continueBuffering) {
+ mSourceStateReady = continueBuffering;
+ if (DEBUG) {
+ Log.d(TAG, "mSourceStateReady: " + String.valueOf(mSourceStateReady));
+ }
+ }
+ long discontinuity = mSource.readDiscontinuity(mTrackIndex);
+ if (discontinuity != SampleSource.NO_DISCONTINUITY) {
+ AUDIO_TRACK.handleDiscontinuity();
+ mPresentationTimeUs = discontinuity;
+ mPresentationCount = 0;
+ clearDecodeState();
+ return;
+ }
+ if (mFormat == null) {
+ readFormat();
+ return;
+ }
+
+ if (mAudioDecoder != null) {
+ mAudioDecoder.maybeInitDecoder(mFormat);
+ }
+ // Process only one sample at a time for doSomeWork() when using FFmpeg decoder.
+ if (processOutput()) {
+ if (!mOutputReady) {
+ while (feedInputBuffer()) {
+ if (mOutputReady) break;
+ }
+ }
+ }
+ mCodecCounters.ensureUpdated();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ private void ensureAudioTrackInitialized() {
+ if (!AUDIO_TRACK.isInitialized()) {
+ try {
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack initialized");
+ }
+ AUDIO_TRACK.initialize();
+ } catch (AudioTrack.InitializationException e) {
+ Log.e(TAG, "Error on AudioTrack initialization", e);
+ notifyAudioTrackInitializationError(e);
+
+ // Do not throw exception here but just disabling audioTrack to keep playing
+ // video without audio.
+ AUDIO_TRACK.setStatus(false);
+ }
+ if (getState() == TrackRenderer.STATE_STARTED) {
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack played");
+ }
+ AUDIO_TRACK.play();
+ }
+ }
+ }
+
+ private void clearDecodeState() {
+ mOutputReady = false;
+ if (mAudioDecoder != null) {
+ mAudioDecoder.resetDecoderState(mDecodingMime);
+ }
+ AUDIO_TRACK.reset();
+ }
+
+ private void releaseDecoder() {
+ if (mAudioDecoder != null) {
+ mAudioDecoder.release();
+ }
+ }
+
+ private void readFormat() throws IOException, ExoPlaybackException {
+ int result =
+ mSource.readData(mTrackIndex, mCurrentPositionUs, mFormatHolder, mSampleHolder);
+ if (result == SampleSource.FORMAT_READ) {
+ onInputFormatChanged(mFormatHolder);
+ }
+ }
+
+ private MediaFormat convertMediaFormatToRaw(MediaFormat format) {
+ return MediaFormat.createAudioFormat(
+ format.trackId,
+ MimeTypes.AUDIO_RAW,
+ format.bitrate,
+ format.maxInputSize,
+ format.durationUs,
+ format.channelCount,
+ format.sampleRate,
+ format.initializationData,
+ format.language);
+ }
+
+ private void onInputFormatChanged(MediaFormatHolder formatHolder) throws ExoPlaybackException {
+ String mimeType = formatHolder.format.mimeType;
+ mUseFrameworkDecoder = MediaCodecAudioDecoder.supportMimeType(mSelector, mimeType);
+ if (mUseFrameworkDecoder) {
+ mAudioDecoder = new MediaCodecAudioDecoder(mSelector);
+ mFormat = formatHolder.format;
+ mAudioDecoder.maybeInitDecoder(mFormat);
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DISABLED);
+ // TODO reimplement ffmeg for google3
+ // Here use else if
+ // (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mimeType)
+ // || MimeTypes.AUDIO_AC3.equalsIgnoreCase(mimeType) && !mAc3Passthrough
+ // then set the audio decoder to ffmpeg
+ } else {
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT);
+ mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE);
+ mFormat = formatHolder.format;
+ releaseDecoder();
+ }
+ mFormatConfigured = true;
+ mMonitor.setEncoding(mimeType);
+ if (DEBUG && !mUseFrameworkDecoder) {
+ Log.d(TAG, "AudioTrack was configured to FORMAT: " + mFormat.toString());
+ }
+ clearDecodeState();
+ if (!mUseFrameworkDecoder) {
+ AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16(), 0);
+ }
+ }
+
+ private void onSampleSizeChanged(int sampleSize) {
+ if (DEBUG) {
+ Log.d(TAG, "Sample size was changed to : " + sampleSize);
+ }
+ clearDecodeState();
+ int audioBufferSize = sampleSize * BUFFERED_SAMPLES_IN_AUDIOTRACK;
+ mSampleSize = sampleSize;
+ AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16(), audioBufferSize);
+ }
+
+ private void onOutputFormatChanged(android.media.MediaFormat format) {
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack was configured to FORMAT: " + format.toString());
+ }
+ AUDIO_TRACK.reconfigure(format, 0);
+ }
+
+ private boolean feedInputBuffer() throws IOException, ExoPlaybackException {
+ if (mInputStreamEnded) {
+ return false;
+ }
+
+ if (mUseFrameworkDecoder) {
+ boolean indexChanged =
+ ((MediaCodecAudioDecoder) mAudioDecoder).getInputIndex()
+ == MediaCodecAudioDecoder.INDEX_INVALID;
+ if (indexChanged) {
+ mSampleHolder.data = mAudioDecoder.getInputBuffer();
+ if (mSampleHolder.data != null) {
+ mSampleHolder.clearData();
+ } else {
+ return false;
+ }
+ }
+ } else {
+ mSampleHolder.data.clear();
+ mSampleHolder.size = 0;
+ }
+ int result =
+ mSource.readData(mTrackIndex, mPresentationTimeUs, mFormatHolder, mSampleHolder);
+ switch (result) {
+ case SampleSource.NOTHING_READ:
+ {
+ return false;
+ }
+ case SampleSource.FORMAT_READ:
+ {
+ Log.i(TAG, "Format was read again");
+ onInputFormatChanged(mFormatHolder);
+ return true;
+ }
+ case SampleSource.END_OF_STREAM:
+ {
+ Log.i(TAG, "End of stream from SampleSource");
+ mInputStreamEnded = true;
+ return false;
+ }
+ default:
+ {
+ if (mSampleHolder.size != mSampleSize
+ && mFormatConfigured
+ && !mUseFrameworkDecoder) {
+ onSampleSizeChanged(mSampleHolder.size);
+ }
+ mSampleHolder.data.flip();
+ if (!mUseFrameworkDecoder) {
+ if (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mDecodingMime)) {
+ mMonitor.addPts(
+ mSampleHolder.timeUs,
+ mOutputBuffer.position(),
+ mSampleHolder.data.get(MP2_HEADER_BITRATE_OFFSET)
+ & MP2_HEADER_BITRATE_MASK);
+ } else {
+ mMonitor.addPts(
+ mSampleHolder.timeUs,
+ mOutputBuffer.position(),
+ mSampleHolder.data.get(AC3_HEADER_BITRATE_OFFSET) & 0xff);
+ }
+ }
+ if (mAudioDecoder != null) {
+ mAudioDecoder.decode(mSampleHolder);
+ if (mUseFrameworkDecoder) {
+ int outputIndex =
+ ((MediaCodecAudioDecoder) mAudioDecoder).getOutputIndex();
+ if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ onOutputFormatChanged(mAudioDecoder.getOutputFormat());
+ return true;
+ } else if (outputIndex < 0) {
+ return true;
+ }
+ if (((MediaCodecAudioDecoder) mAudioDecoder).maybeDecodeOnlyIndex()) {
+ AUDIO_TRACK.handleDiscontinuity();
+ return true;
+ }
+ }
+ ByteBuffer outputBuffer = mAudioDecoder.getDecodedSample();
+ long presentationTimeUs = mAudioDecoder.getDecodedTimeUs();
+ decodeDone(outputBuffer, presentationTimeUs);
+ } else {
+ decodeDone(mSampleHolder.data, mSampleHolder.timeUs);
+ }
+ return true;
+ }
+ }
+ }
+
+ private boolean processOutput() throws ExoPlaybackException {
+ if (mOutputStreamEnded) {
+ return false;
+ }
+ if (!mOutputReady) {
+ if (mInputStreamEnded) {
+ mOutputStreamEnded = true;
+ mEndOfStreamMs = SystemClock.elapsedRealtime();
+ return false;
+ }
+ return true;
+ }
+
+ ensureAudioTrackInitialized();
+ int handleBufferResult;
+ try {
+ // To reduce discontinuity, interpolate presentation time.
+ if (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mDecodingMime)) {
+ mInterpolatedTimeUs =
+ mPresentationTimeUs + mPresentationCount * MP2_SAMPLE_DURATION_US;
+ } else if (!mUseFrameworkDecoder) {
+ mInterpolatedTimeUs =
+ mPresentationTimeUs + mPresentationCount * AC3_SAMPLE_DURATION_US;
+ } else {
+ mInterpolatedTimeUs = mPresentationTimeUs;
+ }
+ handleBufferResult =
+ AUDIO_TRACK.handleBuffer(
+ mOutputBuffer, 0, mOutputBuffer.limit(), mInterpolatedTimeUs);
+ } catch (AudioTrack.WriteException e) {
+ notifyAudioTrackWriteError(e);
+ throw new ExoPlaybackException(e);
+ }
+ if ((handleBufferResult & AudioTrack.RESULT_POSITION_DISCONTINUITY) != 0) {
+ Log.i(TAG, "Play discontinuity happened");
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+ if ((handleBufferResult & AudioTrack.RESULT_BUFFER_CONSUMED) != 0) {
+ mCodecCounters.renderedOutputBufferCount++;
+ mOutputReady = false;
+ if (mUseFrameworkDecoder) {
+ ((MediaCodecAudioDecoder) mAudioDecoder).releaseOutputBuffer();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected long getDurationUs() {
+ return mSource.getFormat(mTrackIndex).durationUs;
+ }
+
+ @Override
+ protected long getBufferedPositionUs() {
+ long pos = mSource.getBufferedPositionUs();
+ return pos == UNKNOWN_TIME_US || pos == END_OF_TRACK_US
+ ? pos
+ : Math.max(pos, getPositionUs());
+ }
+
+ @Override
+ public long getPositionUs() {
+ if (!AUDIO_TRACK.isInitialized()) {
+ return mAudioClock.getPositionUs();
+ } else if (!AUDIO_TRACK.isEnabled()) {
+ if (mInterpolatedTimeUs > 0 && !mUseFrameworkDecoder) {
+ return mInterpolatedTimeUs - ESTIMATED_TRACK_RENDERING_DELAY_US;
+ }
+ return mPresentationTimeUs;
+ }
+ long audioTrackCurrentPositionUs = AUDIO_TRACK.getCurrentPositionUs(isEnded());
+ if (audioTrackCurrentPositionUs == AudioTrack.CURRENT_POSITION_NOT_SET) {
+ mPreviousPositionUs = 0L;
+ if (DEBUG) {
+ long oldPositionUs = Math.max(mCurrentPositionUs, 0);
+ long currentPositionUs = Math.max(mPresentationTimeUs, mCurrentPositionUs);
+ Log.d(
+ TAG,
+ "Audio position is not set, diff in us: "
+ + String.valueOf(currentPositionUs - oldPositionUs));
+ }
+ mCurrentPositionUs = Math.max(mPresentationTimeUs, mCurrentPositionUs);
+ } else {
+ if (mPreviousPositionUs
+ > audioTrackCurrentPositionUs + BACKWARD_AUDIO_TRACK_MOVE_THRESHOLD_US) {
+ Log.e(
+ TAG,
+ "audio_position BACK JUMP: "
+ + (mPreviousPositionUs - audioTrackCurrentPositionUs));
+ mCurrentPositionUs = audioTrackCurrentPositionUs;
+ } else {
+ mCurrentPositionUs = Math.max(mCurrentPositionUs, audioTrackCurrentPositionUs);
+ }
+ mPreviousPositionUs = audioTrackCurrentPositionUs;
+ }
+ long upperBound = mPresentationTimeUs + CURRENT_POSITION_FROM_PTS_LIMIT_US;
+ if (mCurrentPositionUs > upperBound) {
+ mCurrentPositionUs = upperBound;
+ }
+ return mCurrentPositionUs;
+ }
+
+ private void decodeDone(ByteBuffer outputBuffer, long presentationTimeUs) {
+ if (outputBuffer == null || mOutputBuffer == null) {
+ return;
+ }
+ if (presentationTimeUs < 0) {
+ Log.e(TAG, "decodeDone - invalid presentationTimeUs");
+ return;
+ }
+
+ if (TunerDebug.ENABLED) {
+ TunerDebug.setAudioPtsUs(presentationTimeUs);
+ }
+
+ mOutputBuffer.clear();
+ Assertions.checkState(mOutputBuffer.remaining() >= outputBuffer.limit());
+
+ mOutputBuffer.put(outputBuffer);
+ if (presentationTimeUs == mPresentationTimeUs) {
+ mPresentationCount++;
+ } else {
+ mPresentationCount = 0;
+ mPresentationTimeUs = presentationTimeUs;
+ }
+ mOutputBuffer.flip();
+ mOutputReady = true;
+ }
+
+ private void notifyAudioTrackInitializationError(final AudioTrack.InitializationException e) {
+ if (mEventHandler == null || mEventListener == null) {
+ return;
+ }
+ mEventHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mEventListener.onAudioTrackInitializationError(e);
+ }
+ });
+ }
+
+ private void notifyAudioTrackWriteError(final AudioTrack.WriteException e) {
+ if (mEventHandler == null || mEventListener == null) {
+ return;
+ }
+ mEventHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mEventListener.onAudioTrackWriteError(e);
+ }
+ });
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ switch (messageType) {
+ case MSG_SET_VOLUME:
+ float volume = (Float) message;
+ // Workaround: we cannot mute the audio track by setting the volume to 0, we need to
+ // disable the AUDIO_TRACK for this intent. However, enabling/disabling audio track
+ // whenever volume is being set might cause side effects, therefore we only handle
+ // "explicit mute operations", i.e., only after certain non-zero volume has been
+ // set, the subsequent volume setting operations will be consider as mute/un-mute
+ // operations and thus enable/disable the audio track.
+ if (mIsMuted && volume > 0) {
+ mIsMuted = false;
+ if (mEnabled) {
+ setStatus(true);
+ }
+ } else if (!mIsMuted && volume == 0) {
+ mIsMuted = true;
+ if (mEnabled) {
+ setStatus(false);
+ }
+ }
+ AUDIO_TRACK.setVolume(volume);
+ break;
+ case MSG_SET_AUDIO_TRACK:
+ mEnabled = (Integer) message == 1;
+ setStatus(mEnabled);
+ break;
+ case MSG_SET_PLAYBACK_SPEED:
+ mAudioClock.setPlaybackSpeed((Float) message);
+ break;
+ default:
+ super.handleMessage(messageType, message);
+ }
+ }
+
+ private void setStatus(boolean enabled) {
+ if (enabled == AUDIO_TRACK.isEnabled()) {
+ return;
+ }
+ if (!enabled) {
+ // mAudioClock can be different from getPositionUs. In order to sync them,
+ // we set mAudioClock.
+ mAudioClock.setPositionUs(getPositionUs());
+ }
+ AUDIO_TRACK.setStatus(enabled);
+ if (enabled) {
+ // When AUDIO_TRACK is enabled, we need to clear AUDIO_TRACK and seek to
+ // the current position. If not, AUDIO_TRACK has the obsolete data.
+ seekTo(mAudioClock.getPositionUs());
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java
new file mode 100644
index 00000000..b382545f
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java
@@ -0,0 +1,94 @@
+/*
+ * 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.exoplayer.audio;
+
+import android.os.Handler;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaCodecAudioTrackRenderer;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.SampleSource;
+
+/**
+ * MPEG-2 TS audio track renderer.
+ *
+ * <p>Since the audio output from {@link android.media.MediaExtractor} contains extra samples at the
+ * beginning, using original {@link MediaCodecAudioTrackRenderer} as audio renderer causes
+ * asynchronous Audio/Video outputs. This class calculates the offset of audio data and adjust the
+ * presentation times to avoid the asynchronous Audio/Video problem.
+ */
+public class MpegTsMediaCodecAudioTrackRenderer extends MediaCodecAudioTrackRenderer {
+ private final Ac3EventListener mListener;
+
+ public interface Ac3EventListener extends EventListener {
+ /**
+ * Invoked when a {@link android.media.PlaybackParams} set to an {@link
+ * android.media.AudioTrack} is not valid.
+ *
+ * @param e The corresponding exception.
+ */
+ void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e);
+ }
+
+ public MpegTsMediaCodecAudioTrackRenderer(
+ SampleSource source,
+ MediaCodecSelector mediaCodecSelector,
+ Handler eventHandler,
+ EventListener eventListener) {
+ super(source, mediaCodecSelector, eventHandler, eventListener);
+ mListener = (Ac3EventListener) eventListener;
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ if (messageType == MSG_SET_PLAYBACK_PARAMS) {
+ try {
+ super.handleMessage(messageType, message);
+ } catch (IllegalArgumentException e) {
+ if (isAudioTrackSetPlaybackParamsError(e)) {
+ notifyAudioTrackSetPlaybackParamsError(e);
+ }
+ }
+ return;
+ }
+ super.handleMessage(messageType, message);
+ }
+
+ private void notifyAudioTrackSetPlaybackParamsError(final IllegalArgumentException e) {
+ if (eventHandler != null && mListener != null) {
+ eventHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mListener.onAudioTrackSetPlaybackParamsError(e);
+ }
+ });
+ }
+ }
+
+ private static boolean isAudioTrackSetPlaybackParamsError(IllegalArgumentException e) {
+ if (e.getStackTrace() == null || e.getStackTrace().length < 1) {
+ return false;
+ }
+ for (StackTraceElement element : e.getStackTrace()) {
+ String elementString = element.toString();
+ if (elementString.startsWith("android.media.AudioTrack.setPlaybackParams")) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
new file mode 100644
index 00000000..3e4ab103
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
@@ -0,0 +1,683 @@
+/*
+ * 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.exoplayer.buffer;
+
+import android.media.MediaFormat;
+import android.os.ConditionVariable;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.Pair;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.util.CommonUtils;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.google.android.exoplayer.SampleHolder;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.ConcurrentModificationException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Manages {@link SampleChunk} objects.
+ *
+ * <p>The buffer manager can be disabled, while running, if the write throughput to the associated
+ * external storage is detected to be lower than a threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}".
+ * This leads to restarting playback flow.
+ */
+public class BufferManager {
+ private static final String TAG = "BufferManager";
+ private static final boolean DEBUG = false;
+
+ // Constants for the disk write speed checking
+ private static final long MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK =
+ 10L * 1024 * 1024; // Checks for every 10M disk write
+ private static final int MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK = 15 * 1024;
+ private static final int MAXIMUM_SPEED_CHECK_COUNT = 5; // Checks only 5 times
+ private static final int MINIMUM_DISK_WRITE_SPEED_MBPS = 3; // 3 Megabytes per second
+
+ private final SampleChunk.SampleChunkCreator mSampleChunkCreator;
+ // Maps from track name to a map which maps from starting position to {@link SampleChunk}.
+ private final Map<String, SortedMap<Long, Pair<SampleChunk, Integer>>> mChunkMap =
+ new ArrayMap<>();
+ private final Map<String, Long> mStartPositionMap = new ArrayMap<>();
+ private final Map<String, ChunkEvictedListener> mEvictListeners = new ArrayMap<>();
+ private final StorageManager mStorageManager;
+ private long mBufferSize = 0;
+ private final EvictChunkQueueMap mPendingDelete = new EvictChunkQueueMap();
+ private final SampleChunk.ChunkCallback mChunkCallback =
+ new SampleChunk.ChunkCallback() {
+ @Override
+ public void onChunkWrite(SampleChunk chunk) {
+ mBufferSize += chunk.getSize();
+ }
+
+ @Override
+ public void onChunkDelete(SampleChunk chunk) {
+ mBufferSize -= chunk.getSize();
+ }
+ };
+
+ private int mMinSampleSizeForSpeedCheck = MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK;
+ private long mTotalWriteSize;
+ private long mTotalWriteTimeNs;
+ private float mWriteBandwidth = 0.0f;
+ private final AtomicInteger mSpeedCheckCount = new AtomicInteger();
+
+ public interface ChunkEvictedListener {
+ void onChunkEvicted(String id, long createdTimeMs);
+ }
+ /** Handles I/O between BufferManager and {@link SampleExtractor}. */
+ public interface SampleBuffer {
+
+ /**
+ * Initializes SampleBuffer.
+ *
+ * @param Ids track identifiers for storage read/write.
+ * @param mediaFormats meta-data for each track.
+ * @throws IOException
+ */
+ void init(
+ @NonNull List<String> Ids,
+ @NonNull List<com.google.android.exoplayer.MediaFormat> mediaFormats)
+ throws IOException;
+
+ /** Selects the track {@code index} for reading sample data. */
+ void selectTrack(int index);
+
+ /**
+ * Deselects the track at {@code index}, so that no more samples will be read from the
+ * track.
+ */
+ void deselectTrack(int index);
+
+ /**
+ * Writes sample to storage.
+ *
+ * @param index track index
+ * @param sample sample to write at storage
+ * @param conditionVariable notifies the completion of writing sample.
+ * @throws IOException
+ */
+ void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException;
+
+ /** Checks whether storage write speed is slow. */
+ boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs);
+
+ /**
+ * Handles when write speed is slow.
+ *
+ * @throws IOException
+ */
+ void handleWriteSpeedSlow() throws IOException;
+
+ /** Sets the flag when EoS was reached. */
+ void setEos();
+
+ /**
+ * Reads the next sample in the track at index {@code track} into {@code sampleHolder},
+ * returning {@link com.google.android.exoplayer.SampleSource#SAMPLE_READ} if it is
+ * available. If the next sample is not available, returns {@link
+ * com.google.android.exoplayer.SampleSource#NOTHING_READ}.
+ */
+ int readSample(int index, SampleHolder outSample);
+
+ /** Seeks to the specified time in microseconds. */
+ void seekTo(long positionUs);
+
+ /** Returns an estimate of the position up to which data is buffered. */
+ long getBufferedPositionUs();
+
+ /** Returns whether there is buffered data. */
+ boolean continueBuffering(long positionUs);
+
+ /**
+ * Cleans up and releases everything.
+ *
+ * @throws IOException
+ */
+ void release() throws IOException;
+ }
+
+ /** A Track format which will be loaded and saved from the permanent storage for recordings. */
+ public static class TrackFormat {
+
+ /**
+ * The track id for the specified track. The track id will be used as a track identifier for
+ * recordings.
+ */
+ public final String trackId;
+
+ /** The {@link MediaFormat} for the specified track. */
+ public final MediaFormat format;
+
+ /**
+ * Creates TrackFormat.
+ *
+ * @param trackId
+ * @param format
+ */
+ public TrackFormat(String trackId, MediaFormat format) {
+ this.trackId = trackId;
+ this.format = format;
+ }
+ }
+
+ /** A Holder for a sample position which will be loaded from the index file for recordings. */
+ public static class PositionHolder {
+
+ /**
+ * The current sample position in microseconds. The position is identical to the
+ * PTS(presentation time stamp) of the sample.
+ */
+ public final long positionUs;
+
+ /** Base sample position for the current {@link SampleChunk}. */
+ public final long basePositionUs;
+
+ /** The file offset for the current sample in the current {@link SampleChunk}. */
+ public final int offset;
+
+ /**
+ * Creates a holder for a specific position in the recording.
+ *
+ * @param positionUs
+ * @param offset
+ */
+ public PositionHolder(long positionUs, long basePositionUs, int offset) {
+ this.positionUs = positionUs;
+ this.basePositionUs = basePositionUs;
+ this.offset = offset;
+ }
+ }
+
+ /** Storage configuration and policy manager for {@link BufferManager} */
+ public interface StorageManager {
+
+ /**
+ * Provides eligible storage directory for {@link BufferManager}.
+ *
+ * @return a directory to save buffer(chunks) and meta files
+ */
+ File getBufferDir();
+
+ /**
+ * Informs whether the storage is used for persistent use. (eg. dvr recording/play)
+ *
+ * @return {@code true} if stored files are persistent
+ */
+ boolean isPersistent();
+
+ /**
+ * Informs whether the storage usage exceeds pre-determined size.
+ *
+ * @param bufferSize the current total usage of Storage in bytes.
+ * @param pendingDelete the current storage usage which will be deleted in near future by
+ * bytes
+ * @return {@code true} if it reached pre-determined max size
+ */
+ boolean reachedStorageMax(long bufferSize, long pendingDelete);
+
+ /**
+ * Informs whether the storage has enough remained space.
+ *
+ * @param pendingDelete the current storage usage which will be deleted in near future by
+ * bytes
+ * @return {@code true} if it has enough space
+ */
+ boolean hasEnoughBuffer(long pendingDelete);
+
+ /**
+ * Reads track name & {@link MediaFormat} from storage.
+ *
+ * @param isAudio {@code true} if it is for audio track
+ * @return {@link List} of TrackFormat
+ */
+ List<TrackFormat> readTrackInfoFiles(boolean isAudio);
+
+ /**
+ * Reads key sample positions for each written sample from storage.
+ *
+ * @param trackId track name
+ * @return indexes of the specified track
+ * @throws IOException
+ */
+ ArrayList<PositionHolder> readIndexFile(String trackId) throws IOException;
+
+ /**
+ * Writes track information to storage.
+ *
+ * @param formatList {@list List} of TrackFormat
+ * @param isAudio {@code true} if it is for audio track
+ * @throws IOException
+ */
+ void writeTrackInfoFiles(List<TrackFormat> formatList, boolean isAudio) throws IOException;
+
+ /**
+ * Writes index file to storage.
+ *
+ * @param trackName track name
+ * @param index {@link SampleChunk} container
+ * @throws IOException
+ */
+ void writeIndexFile(String trackName, SortedMap<Long, Pair<SampleChunk, Integer>> index)
+ throws IOException;
+ }
+
+ private static class EvictChunkQueueMap {
+ private final Map<String, LinkedList<SampleChunk>> mEvictMap = new ArrayMap<>();
+ private long mSize;
+
+ private void init(String key) {
+ mEvictMap.put(key, new LinkedList<>());
+ }
+
+ private void add(String key, SampleChunk chunk) {
+ LinkedList<SampleChunk> queue = mEvictMap.get(key);
+ if (queue != null) {
+ mSize += chunk.getSize();
+ queue.add(chunk);
+ }
+ }
+
+ private SampleChunk poll(String key, long startPositionUs) {
+ LinkedList<SampleChunk> queue = mEvictMap.get(key);
+ if (queue != null) {
+ SampleChunk chunk = queue.peek();
+ if (chunk != null && chunk.getStartPositionUs() < startPositionUs) {
+ mSize -= chunk.getSize();
+ return queue.poll();
+ }
+ }
+ return null;
+ }
+
+ private long getSize() {
+ return mSize;
+ }
+
+ private void release() {
+ for (Map.Entry<String, LinkedList<SampleChunk>> entry : mEvictMap.entrySet()) {
+ for (SampleChunk chunk : entry.getValue()) {
+ SampleChunk.IoState.release(chunk, true);
+ }
+ }
+ mEvictMap.clear();
+ mSize = 0;
+ }
+ }
+
+ public BufferManager(StorageManager storageManager) {
+ this(storageManager, new SampleChunk.SampleChunkCreator());
+ }
+
+ public BufferManager(
+ StorageManager storageManager, SampleChunk.SampleChunkCreator sampleChunkCreator) {
+ mStorageManager = storageManager;
+ mSampleChunkCreator = sampleChunkCreator;
+ }
+
+ public void registerChunkEvictedListener(String id, ChunkEvictedListener listener) {
+ mEvictListeners.put(id, listener);
+ }
+
+ public void unregisterChunkEvictedListener(String id) {
+ mEvictListeners.remove(id);
+ }
+
+ private static String getFileName(String id, long positionUs) {
+ return String.format(Locale.ENGLISH, "%s_%016x.chunk", id, positionUs);
+ }
+
+ /**
+ * Creates a new {@link SampleChunk} for caching samples if it is needed.
+ *
+ * @param id the name of the track
+ * @param positionUs current position to write a sample in micro seconds.
+ * @param samplePool {@link SamplePool} for the fast creation of samples.
+ * @param currentChunk the current {@link SampleChunk} to write, {@code null} when to create a
+ * new {@link SampleChunk}.
+ * @param currentOffset the current offset to write.
+ * @return returns the created {@link SampleChunk}.
+ * @throws IOException
+ */
+ public SampleChunk createNewWriteFileIfNeeded(
+ String id,
+ long positionUs,
+ SamplePool samplePool,
+ SampleChunk currentChunk,
+ int currentOffset)
+ throws IOException {
+ if (!maybeEvictChunk()) {
+ throw new IOException("Not enough storage space");
+ }
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = mChunkMap.get(id);
+ if (map == null) {
+ map = new TreeMap<>();
+ mChunkMap.put(id, map);
+ mStartPositionMap.put(id, positionUs);
+ mPendingDelete.init(id);
+ }
+ if (currentChunk == null) {
+ File file = new File(mStorageManager.getBufferDir(), getFileName(id, positionUs));
+ SampleChunk sampleChunk =
+ mSampleChunkCreator.createSampleChunk(
+ samplePool, file, positionUs, mChunkCallback);
+ map.put(positionUs, new Pair(sampleChunk, 0));
+ return sampleChunk;
+ } else {
+ map.put(positionUs, new Pair(currentChunk, currentOffset));
+ return null;
+ }
+ }
+
+ /**
+ * Loads a track using {@link BufferManager.StorageManager}.
+ *
+ * @param trackId the name of the track.
+ * @param samplePool {@link SamplePool} for the fast creation of samples.
+ * @throws IOException
+ */
+ public void loadTrackFromStorage(String trackId, SamplePool samplePool) throws IOException {
+ ArrayList<PositionHolder> keyPositions = mStorageManager.readIndexFile(trackId);
+ long startPositionUs = keyPositions.size() > 0 ? keyPositions.get(0).positionUs : 0;
+
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = mChunkMap.get(trackId);
+ if (map == null) {
+ map = new TreeMap<>();
+ mChunkMap.put(trackId, map);
+ mStartPositionMap.put(trackId, startPositionUs);
+ mPendingDelete.init(trackId);
+ }
+ SampleChunk chunk = null;
+ long basePositionUs = -1;
+ for (PositionHolder position : keyPositions) {
+ if (position.basePositionUs != basePositionUs) {
+ chunk =
+ mSampleChunkCreator.loadSampleChunkFromFile(
+ samplePool,
+ mStorageManager.getBufferDir(),
+ getFileName(trackId, position.positionUs),
+ position.positionUs,
+ mChunkCallback,
+ chunk);
+ basePositionUs = position.basePositionUs;
+ }
+ map.put(position.positionUs, new Pair(chunk, position.offset));
+ }
+ }
+
+ /**
+ * Finds a {@link SampleChunk} for the specified track name and the position.
+ *
+ * @param id the name of the track.
+ * @param positionUs the position.
+ * @return returns the found {@link SampleChunk}.
+ */
+ public Pair<SampleChunk, Integer> getReadFile(String id, long positionUs) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = mChunkMap.get(id);
+ if (map == null) {
+ return null;
+ }
+ Pair<SampleChunk, Integer> ret;
+ SortedMap<Long, Pair<SampleChunk, Integer>> headMap = map.headMap(positionUs + 1);
+ if (!headMap.isEmpty()) {
+ ret = headMap.get(headMap.lastKey());
+ } else {
+ ret = map.get(map.firstKey());
+ }
+ return ret;
+ }
+
+ /**
+ * Evicts chunks which are ready to be evicted for the specified track
+ *
+ * @param id the specified track
+ * @param earlierThanPositionUs the start position of the {@link SampleChunk} should be earlier
+ * than
+ */
+ public void evictChunks(String id, long earlierThanPositionUs) {
+ SampleChunk chunk = null;
+ while ((chunk = mPendingDelete.poll(id, earlierThanPositionUs)) != null) {
+ SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent());
+ }
+ }
+
+ /**
+ * Returns the start position of the specified track in micro seconds.
+ *
+ * @param id the specified track
+ */
+ public long getStartPositionUs(String id) {
+ Long ret = mStartPositionMap.get(id);
+ return ret == null ? 0 : ret;
+ }
+
+ private boolean maybeEvictChunk() {
+ long pendingDelete = mPendingDelete.getSize();
+ while (mStorageManager.reachedStorageMax(mBufferSize, pendingDelete)
+ || !mStorageManager.hasEnoughBuffer(pendingDelete)) {
+ if (mStorageManager.isPersistent()) {
+ // Since chunks are persistent, we cannot evict chunks.
+ return false;
+ }
+ SortedMap<Long, Pair<SampleChunk, Integer>> earliestChunkMap = null;
+ SampleChunk earliestChunk = null;
+ String earliestChunkId = null;
+ for (Map.Entry<String, SortedMap<Long, Pair<SampleChunk, Integer>>> entry :
+ mChunkMap.entrySet()) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = entry.getValue();
+ if (map.isEmpty()) {
+ continue;
+ }
+ SampleChunk chunk = map.get(map.firstKey()).first;
+ if (earliestChunk == null
+ || chunk.getCreatedTimeMs() < earliestChunk.getCreatedTimeMs()) {
+ earliestChunkMap = map;
+ earliestChunk = chunk;
+ earliestChunkId = entry.getKey();
+ }
+ }
+ if (earliestChunk == null) {
+ break;
+ }
+ mPendingDelete.add(earliestChunkId, earliestChunk);
+ earliestChunkMap.remove(earliestChunk.getStartPositionUs());
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "bufferSize = %d; pendingDelete = %b; "
+ + "earliestChunk size = %d; %s@%d (%s)",
+ mBufferSize,
+ pendingDelete,
+ earliestChunk.getSize(),
+ earliestChunkId,
+ earliestChunk.getStartPositionUs(),
+ CommonUtils.toIsoDateTimeString(earliestChunk.getCreatedTimeMs())));
+ }
+ ChunkEvictedListener listener = mEvictListeners.get(earliestChunkId);
+ if (listener != null) {
+ listener.onChunkEvicted(earliestChunkId, earliestChunk.getCreatedTimeMs());
+ }
+ pendingDelete = mPendingDelete.getSize();
+ }
+ for (Map.Entry<String, SortedMap<Long, Pair<SampleChunk, Integer>>> entry :
+ mChunkMap.entrySet()) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = entry.getValue();
+ if (map.isEmpty()) {
+ continue;
+ }
+ mStartPositionMap.put(entry.getKey(), map.firstKey());
+ }
+ return true;
+ }
+
+ /**
+ * Reads track information which includes {@link MediaFormat}.
+ *
+ * @return returns all track information which is found by {@link BufferManager.StorageManager}.
+ * @throws IOException
+ */
+ public List<TrackFormat> readTrackInfoFiles() throws IOException {
+ List<TrackFormat> trackFormatList = new ArrayList<>();
+ trackFormatList.addAll(mStorageManager.readTrackInfoFiles(false));
+ trackFormatList.addAll(mStorageManager.readTrackInfoFiles(true));
+ if (trackFormatList.isEmpty()) {
+ throw new IOException("No track information to load");
+ }
+ return trackFormatList;
+ }
+
+ /**
+ * Writes track information and index information for all tracks.
+ *
+ * @param audios list of audio track information
+ * @param videos list of audio track information
+ * @throws IOException
+ */
+ public void writeMetaFiles(List<TrackFormat> audios, List<TrackFormat> videos)
+ throws IOException {
+ if (audios.isEmpty() && videos.isEmpty()) {
+ throw new IOException("No track information to save");
+ }
+ if (!audios.isEmpty()) {
+ mStorageManager.writeTrackInfoFiles(audios, true);
+ for (TrackFormat trackFormat : audios) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map =
+ mChunkMap.get(trackFormat.trackId);
+ if (map == null) {
+ throw new IOException("Audio track index missing");
+ }
+ mStorageManager.writeIndexFile(trackFormat.trackId, map);
+ }
+ }
+ if (!videos.isEmpty()) {
+ mStorageManager.writeTrackInfoFiles(videos, false);
+ for (TrackFormat trackFormat : videos) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map =
+ mChunkMap.get(trackFormat.trackId);
+ if (map == null) {
+ throw new IOException("Video track index missing");
+ }
+ mStorageManager.writeIndexFile(trackFormat.trackId, map);
+ }
+ }
+ }
+
+ /** Releases all the resources. */
+ public void release() {
+ try {
+ mPendingDelete.release();
+ for (Map.Entry<String, SortedMap<Long, Pair<SampleChunk, Integer>>> entry :
+ mChunkMap.entrySet()) {
+ SampleChunk toRelease = null;
+ for (Pair<SampleChunk, Integer> positions : entry.getValue().values()) {
+ if (toRelease != positions.first) {
+ toRelease = positions.first;
+ SampleChunk.IoState.release(toRelease, !mStorageManager.isPersistent());
+ }
+ }
+ }
+ mChunkMap.clear();
+ } catch (ConcurrentModificationException | NullPointerException e) {
+ // TODO: remove this after it it confirmed that race condition issues are resolved.
+ // b/32492258, b/32373376
+ SoftPreconditions.checkState(
+ false, "Exception on BufferManager#release: ", e.toString());
+ }
+ }
+
+ private void resetWriteStat(float writeBandwidth) {
+ mWriteBandwidth = writeBandwidth;
+ mTotalWriteSize = 0;
+ mTotalWriteTimeNs = 0;
+ }
+
+ /** Adds a disk write sample size to calculate the average disk write bandwidth. */
+ public void addWriteStat(long size, long timeNs) {
+ if (size >= mMinSampleSizeForSpeedCheck) {
+ mTotalWriteSize += size;
+ mTotalWriteTimeNs += timeNs;
+ }
+ }
+
+ /**
+ * Returns if the average disk write bandwidth is slower than threshold {@code
+ * MINIMUM_DISK_WRITE_SPEED_MBPS}.
+ */
+ public boolean isWriteSlow() {
+ if (mTotalWriteSize < MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK) {
+ return false;
+ }
+
+ // Checks write speed for only MAXIMUM_SPEED_CHECK_COUNT times to ignore outliers
+ // by temporary system overloading during the playback.
+ if (mSpeedCheckCount.get() > MAXIMUM_SPEED_CHECK_COUNT) {
+ return false;
+ }
+ mSpeedCheckCount.incrementAndGet();
+ float megabytePerSecond = calculateWriteBandwidth();
+ resetWriteStat(megabytePerSecond);
+ if (DEBUG) {
+ Log.d(TAG, "Measured disk write performance: " + megabytePerSecond + "MBps");
+ }
+ return megabytePerSecond < MINIMUM_DISK_WRITE_SPEED_MBPS;
+ }
+
+ /**
+ * Returns recent write bandwidth in MBps. If recent bandwidth is not available, returns {float
+ * -1.0f}.
+ */
+ public float getWriteBandwidth() {
+ return mWriteBandwidth == 0.0f ? -1.0f : mWriteBandwidth;
+ }
+
+ private float calculateWriteBandwidth() {
+ if (mTotalWriteTimeNs == 0) {
+ return -1;
+ }
+ return ((float) mTotalWriteSize * 1000 / mTotalWriteTimeNs);
+ }
+
+ /**
+ * Returns if {@link BufferManager} has checked the write speed, which is suitable for
+ * Trickplay.
+ */
+ @VisibleForTesting
+ public boolean hasSpeedCheckDone() {
+ return mSpeedCheckCount.get() > 0;
+ }
+
+ /**
+ * Sets minimum sample size for write speed check.
+ *
+ * @param sampleSize minimum sample size for write speed check.
+ */
+ @VisibleForTesting
+ public void setMinimumSampleSizeForSpeedCheck(int sampleSize) {
+ mMinSampleSizeForSpeedCheck = sampleSize;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java
new file mode 100644
index 00000000..2a58ffcf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java
@@ -0,0 +1,391 @@
+/*
+ * 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.exoplayer.buffer;
+
+import android.media.MediaFormat;
+import android.util.Log;
+import android.util.Pair;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import com.google.protobuf.nano.MessageNano;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+
+/** Manages DVR storage. */
+public class DvrStorageManager implements BufferManager.StorageManager {
+ private static final String TAG = "DvrStorageManager";
+
+ // TODO: make serializable classes and use protobuf after internal data structure is finalized.
+ private static final String KEY_PIXEL_WIDTH_HEIGHT_RATIO =
+ "com.google.android.videos.pixelWidthHeightRatio";
+ private static final String META_FILE_TYPE_AUDIO = "audio";
+ private static final String META_FILE_TYPE_VIDEO = "video";
+ private static final String META_FILE_TYPE_CAPTION = "caption";
+ private static final String META_FILE_SUFFIX = ".meta";
+ private static final String IDX_FILE_SUFFIX = ".idx";
+ private static final String IDX_FILE_SUFFIX_V2 = IDX_FILE_SUFFIX + "2";
+
+ // Size of minimum reserved storage buffer which will be used to save meta files
+ // and index files after actual recording finished.
+ private static final long MIN_BUFFER_BYTES = 256L * 1024 * 1024;
+ private static final int NO_VALUE = -1;
+ private static final long NO_VALUE_LONG = -1L;
+
+ private final File mBufferDir;
+
+ // {@code true} when this is for recording, {@code false} when this is for replaying.
+ private final boolean mIsRecording;
+
+ public DvrStorageManager(File file, boolean isRecording) {
+ mBufferDir = file;
+ mBufferDir.mkdirs();
+ mIsRecording = isRecording;
+ }
+
+ @Override
+ public File getBufferDir() {
+ return mBufferDir;
+ }
+
+ @Override
+ public boolean isPersistent() {
+ return true;
+ }
+
+ @Override
+ public boolean reachedStorageMax(long bufferSize, long pendingDelete) {
+ return false;
+ }
+
+ @Override
+ public boolean hasEnoughBuffer(long pendingDelete) {
+ return !mIsRecording || mBufferDir.getUsableSpace() >= MIN_BUFFER_BYTES;
+ }
+
+ private void readFormatInt(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ int val = in.readInt();
+ if (val != NO_VALUE) {
+ format.setInteger(key, val);
+ }
+ }
+
+ private void readFormatLong(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ long val = in.readLong();
+ if (val != NO_VALUE_LONG) {
+ format.setLong(key, val);
+ }
+ }
+
+ private void readFormatFloat(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ float val = in.readFloat();
+ if (val != NO_VALUE) {
+ format.setFloat(key, val);
+ }
+ }
+
+ private String readString(DataInputStream in) throws IOException {
+ int len = in.readInt();
+ if (len <= 0) {
+ return null;
+ }
+ byte[] strBytes = new byte[len];
+ in.readFully(strBytes);
+ return new String(strBytes, StandardCharsets.UTF_8);
+ }
+
+ private void readFormatString(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ String str = readString(in);
+ if (str != null) {
+ format.setString(key, str);
+ }
+ }
+
+ private void readFormatStringOptional(DataInputStream in, MediaFormat format, String key) {
+ try {
+ String str = readString(in);
+ if (str != null) {
+ format.setString(key, str);
+ }
+ } catch (IOException e) {
+ // Since we are reading optional field, ignore the exception.
+ }
+ }
+
+ private ByteBuffer readByteBuffer(DataInputStream in) throws IOException {
+ int len = in.readInt();
+ if (len <= 0) {
+ return null;
+ }
+ byte[] bytes = new byte[len];
+ in.readFully(bytes);
+ ByteBuffer buffer = ByteBuffer.allocate(len);
+ buffer.put(bytes);
+ buffer.flip();
+
+ return buffer;
+ }
+
+ private void readFormatByteBuffer(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ ByteBuffer buffer = readByteBuffer(in);
+ if (buffer != null) {
+ format.setByteBuffer(key, buffer);
+ }
+ }
+
+ @Override
+ public List<BufferManager.TrackFormat> readTrackInfoFiles(boolean isAudio) {
+ List<BufferManager.TrackFormat> trackFormatList = new ArrayList<>();
+ int index = 0;
+ boolean trackNotFound = false;
+ do {
+ String fileName =
+ (isAudio ? META_FILE_TYPE_AUDIO : META_FILE_TYPE_VIDEO)
+ + ((index == 0) ? META_FILE_SUFFIX : (index + META_FILE_SUFFIX));
+ File file = new File(getBufferDir(), fileName);
+ try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
+ String name = readString(in);
+ MediaFormat format = new MediaFormat();
+ readFormatString(in, format, MediaFormat.KEY_MIME);
+ readFormatInt(in, format, MediaFormat.KEY_MAX_INPUT_SIZE);
+ readFormatInt(in, format, MediaFormat.KEY_WIDTH);
+ readFormatInt(in, format, MediaFormat.KEY_HEIGHT);
+ readFormatInt(in, format, MediaFormat.KEY_CHANNEL_COUNT);
+ readFormatInt(in, format, MediaFormat.KEY_SAMPLE_RATE);
+ readFormatFloat(in, format, KEY_PIXEL_WIDTH_HEIGHT_RATIO);
+ for (int i = 0; i < 3; ++i) {
+ readFormatByteBuffer(in, format, "csd-" + i);
+ }
+ readFormatLong(in, format, MediaFormat.KEY_DURATION);
+
+ // This is optional since language field is added later.
+ readFormatStringOptional(in, format, MediaFormat.KEY_LANGUAGE);
+ trackFormatList.add(new BufferManager.TrackFormat(name, format));
+ } catch (IOException e) {
+ trackNotFound = true;
+ }
+ index++;
+ } while (!trackNotFound);
+ return trackFormatList;
+ }
+
+ /**
+ * Reads caption information from files.
+ *
+ * @return a list of {@link AtscCaptionTrack} objects which store caption information.
+ */
+ public List<AtscCaptionTrack> readCaptionInfoFiles() {
+ List<AtscCaptionTrack> tracks = new ArrayList<>();
+ int index = 0;
+ boolean trackNotFound = false;
+ do {
+ String fileName =
+ META_FILE_TYPE_CAPTION
+ + ((index == 0) ? META_FILE_SUFFIX : (index + META_FILE_SUFFIX));
+ File file = new File(getBufferDir(), fileName);
+ try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
+ byte[] data = new byte[(int) file.length()];
+ in.read(data);
+ tracks.add(AtscCaptionTrack.parseFrom(data));
+ } catch (IOException e) {
+ trackNotFound = true;
+ }
+ index++;
+ } while (!trackNotFound);
+ return tracks;
+ }
+
+ private ArrayList<BufferManager.PositionHolder> readOldIndexFile(File indexFile)
+ throws IOException {
+ ArrayList<BufferManager.PositionHolder> indices = new ArrayList<>();
+ try (DataInputStream in = new DataInputStream(new FileInputStream(indexFile))) {
+ long count = in.readLong();
+ for (long i = 0; i < count; ++i) {
+ long positionUs = in.readLong();
+ indices.add(new BufferManager.PositionHolder(positionUs, positionUs, 0));
+ }
+ return indices;
+ }
+ }
+
+ private ArrayList<BufferManager.PositionHolder> readNewIndexFile(File indexFile)
+ throws IOException {
+ ArrayList<BufferManager.PositionHolder> indices = new ArrayList<>();
+ try (DataInputStream in = new DataInputStream(new FileInputStream(indexFile))) {
+ long count = in.readLong();
+ for (long i = 0; i < count; ++i) {
+ long positionUs = in.readLong();
+ long basePositionUs = in.readLong();
+ int offset = in.readInt();
+ indices.add(new BufferManager.PositionHolder(positionUs, basePositionUs, offset));
+ }
+ return indices;
+ }
+ }
+
+ @Override
+ public ArrayList<BufferManager.PositionHolder> readIndexFile(String trackId)
+ throws IOException {
+ File file = new File(getBufferDir(), trackId + IDX_FILE_SUFFIX_V2);
+ if (file.exists()) {
+ return readNewIndexFile(file);
+ } else {
+ return readOldIndexFile(new File(getBufferDir(), trackId + IDX_FILE_SUFFIX));
+ }
+ }
+
+ private void writeFormatInt(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeInt(format.getInteger(key));
+ } else {
+ out.writeInt(NO_VALUE);
+ }
+ }
+
+ private void writeFormatLong(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeLong(format.getLong(key));
+ } else {
+ out.writeLong(NO_VALUE_LONG);
+ }
+ }
+
+ private void writeFormatFloat(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeFloat(format.getFloat(key));
+ } else {
+ out.writeFloat(NO_VALUE);
+ }
+ }
+
+ private void writeString(DataOutputStream out, String str) throws IOException {
+ byte[] data = str.getBytes(StandardCharsets.UTF_8);
+ out.writeInt(data.length);
+ if (data.length > 0) {
+ out.write(data);
+ }
+ }
+
+ private void writeFormatString(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ writeString(out, format.getString(key));
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ private void writeByteBuffer(DataOutputStream out, ByteBuffer buffer) throws IOException {
+ byte[] data = new byte[buffer.limit()];
+ buffer.get(data);
+ buffer.flip();
+ out.writeInt(data.length);
+ if (data.length > 0) {
+ out.write(data);
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ private void writeFormatByteBuffer(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ writeByteBuffer(out, format.getByteBuffer(key));
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ @Override
+ public void writeTrackInfoFiles(List<BufferManager.TrackFormat> formatList, boolean isAudio)
+ throws IOException {
+ for (int i = 0; i < formatList.size(); ++i) {
+ BufferManager.TrackFormat trackFormat = formatList.get(i);
+ String fileName =
+ (isAudio ? META_FILE_TYPE_AUDIO : META_FILE_TYPE_VIDEO)
+ + ((i == 0) ? META_FILE_SUFFIX : (i + META_FILE_SUFFIX));
+ File file = new File(getBufferDir(), fileName);
+ try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
+ writeString(out, trackFormat.trackId);
+ writeFormatString(out, trackFormat.format, MediaFormat.KEY_MIME);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_MAX_INPUT_SIZE);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_WIDTH);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_HEIGHT);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_CHANNEL_COUNT);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_SAMPLE_RATE);
+ writeFormatFloat(out, trackFormat.format, KEY_PIXEL_WIDTH_HEIGHT_RATIO);
+ for (int j = 0; j < 3; ++j) {
+ writeFormatByteBuffer(out, trackFormat.format, "csd-" + j);
+ }
+ writeFormatLong(out, trackFormat.format, MediaFormat.KEY_DURATION);
+ writeFormatString(out, trackFormat.format, MediaFormat.KEY_LANGUAGE);
+ }
+ }
+ }
+
+ /**
+ * Writes caption information to files.
+ *
+ * @param tracks a list of {@link AtscCaptionTrack} objects which store caption information.
+ */
+ public void writeCaptionInfoFiles(List<AtscCaptionTrack> tracks) {
+ if (tracks == null || tracks.isEmpty()) {
+ return;
+ }
+ for (int i = 0; i < tracks.size(); i++) {
+ AtscCaptionTrack track = tracks.get(i);
+ String fileName =
+ META_FILE_TYPE_CAPTION + ((i == 0) ? META_FILE_SUFFIX : (i + META_FILE_SUFFIX));
+ File file = new File(getBufferDir(), fileName);
+ try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
+ out.write(MessageNano.toByteArray(track));
+ } catch (Exception e) {
+ Log.e(TAG, "Fail to write caption info to files", e);
+ }
+ }
+ }
+
+ @Override
+ public void writeIndexFile(String trackName, SortedMap<Long, Pair<SampleChunk, Integer>> index)
+ throws IOException {
+ File indexFile = new File(getBufferDir(), trackName + IDX_FILE_SUFFIX_V2);
+ try (DataOutputStream out = new DataOutputStream(new FileOutputStream(indexFile))) {
+ out.writeLong(index.size());
+ for (Map.Entry<Long, Pair<SampleChunk, Integer>> entry : index.entrySet()) {
+ out.writeLong(entry.getKey());
+ out.writeLong(entry.getValue().first.getStartPositionUs());
+ out.writeInt(entry.getValue().second);
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java
new file mode 100644
index 00000000..ebf00f59
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java
@@ -0,0 +1,303 @@
+/*
+ * 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.exoplayer.buffer;
+
+import android.os.ConditionVariable;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.util.Assertions;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Handles I/O between {@link SampleExtractor} and {@link BufferManager}.Reads & writes samples
+ * from/to {@link SampleChunk} which is backed by physical storage.
+ */
+public class RecordingSampleBuffer
+ implements BufferManager.SampleBuffer, BufferManager.ChunkEvictedListener {
+ private static final String TAG = "RecordingSampleBuffer";
+
+ @IntDef({BUFFER_REASON_LIVE_PLAYBACK, BUFFER_REASON_RECORDED_PLAYBACK, BUFFER_REASON_RECORDING})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface BufferReason {}
+
+ /** A buffer reason for live-stream playback. */
+ public static final int BUFFER_REASON_LIVE_PLAYBACK = 0;
+
+ /** A buffer reason for playback of a recorded program. */
+ public static final int BUFFER_REASON_RECORDED_PLAYBACK = 1;
+
+ /** A buffer reason for recording a program. */
+ public static final int BUFFER_REASON_RECORDING = 2;
+
+ /** The minimum duration to support seek in Trickplay. */
+ static final long MIN_SEEK_DURATION_US = TimeUnit.MILLISECONDS.toMicros(500);
+
+ /** The duration of a {@link SampleChunk} for recordings. */
+ static final long RECORDING_CHUNK_DURATION_US = MIN_SEEK_DURATION_US * 1200; // 10 minutes
+
+ private static final long BUFFER_WRITE_TIMEOUT_MS = 10 * 1000; // 10 seconds
+ private static final long BUFFER_NEEDED_US =
+ 1000L * Math.max(MpegTsPlayer.MIN_BUFFER_MS, MpegTsPlayer.MIN_REBUFFER_MS);
+
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+ private final @BufferReason int mBufferReason;
+
+ private int mTrackCount;
+ private boolean[] mTrackSelected;
+ private List<SampleQueue> mReadSampleQueues;
+ private final SamplePool mSamplePool = new SamplePool();
+ private long mLastBufferedPositionUs = C.UNKNOWN_TIME_US;
+ private long mCurrentPlaybackPositionUs = 0;
+
+ // An error in I/O thread of {@link SampleChunkIoHelper} will be notified.
+ private volatile boolean mError;
+
+ // Eos was reached in I/O thread of {@link SampleChunkIoHelper}.
+ private volatile boolean mEos;
+ private SampleChunkIoHelper mSampleChunkIoHelper;
+ private final SampleChunkIoHelper.IoCallback mIoCallback =
+ new SampleChunkIoHelper.IoCallback() {
+ @Override
+ public void onIoReachedEos() {
+ mEos = true;
+ }
+
+ @Override
+ public void onIoError() {
+ mError = true;
+ }
+ };
+
+ /**
+ * Creates {@link BufferManager.SampleBuffer} with cached I/O backed by physical storage (e.g.
+ * trickplay,recording,recorded-playback).
+ *
+ * @param bufferManager the manager of {@link SampleChunk}
+ * @param bufferListener the listener for buffer I/O event
+ * @param enableTrickplay {@code true} when trickplay should be enabled
+ * @param bufferReason the reason for caching samples {@link RecordingSampleBuffer.BufferReason}
+ */
+ public RecordingSampleBuffer(
+ BufferManager bufferManager,
+ PlaybackBufferListener bufferListener,
+ boolean enableTrickplay,
+ @BufferReason int bufferReason) {
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ if (bufferListener != null) {
+ bufferListener.onBufferStateChanged(enableTrickplay);
+ }
+ mBufferReason = bufferReason;
+ }
+
+ @Override
+ public void init(@NonNull List<String> ids, @NonNull List<MediaFormat> mediaFormats)
+ throws IOException {
+ mTrackCount = ids.size();
+ if (mTrackCount <= 0) {
+ throw new IOException("No tracks to initialize");
+ }
+ mTrackSelected = new boolean[mTrackCount];
+ mReadSampleQueues = new ArrayList<>();
+ mSampleChunkIoHelper =
+ new SampleChunkIoHelper(
+ ids, mediaFormats, mBufferReason, mBufferManager, mSamplePool, mIoCallback);
+ for (int i = 0; i < mTrackCount; ++i) {
+ mReadSampleQueues.add(i, new SampleQueue(mSamplePool));
+ }
+ mSampleChunkIoHelper.init();
+ for (int i = 0; i < mTrackCount; ++i) {
+ mBufferManager.registerChunkEvictedListener(ids.get(i), RecordingSampleBuffer.this);
+ }
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ if (!mTrackSelected[index]) {
+ mTrackSelected[index] = true;
+ mReadSampleQueues.get(index).clear();
+ mSampleChunkIoHelper.openRead(index, mCurrentPlaybackPositionUs);
+ }
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ if (mTrackSelected[index]) {
+ mTrackSelected[index] = false;
+ mReadSampleQueues.get(index).clear();
+ mSampleChunkIoHelper.closeRead(index);
+ }
+ }
+
+ @Override
+ public void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException {
+ mSampleChunkIoHelper.writeSample(index, sample, conditionVariable);
+
+ if (!conditionVariable.block(BUFFER_WRITE_TIMEOUT_MS)) {
+ Log.e(TAG, "Error: Serious delay on writing buffer");
+ conditionVariable.block();
+ }
+ }
+
+ @Override
+ public boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs) {
+ if (mBufferReason == BUFFER_REASON_RECORDED_PLAYBACK) {
+ return false;
+ }
+ mBufferManager.addWriteStat(sampleSize, writeDurationNs);
+ return mBufferManager.isWriteSlow();
+ }
+
+ @Override
+ public void handleWriteSpeedSlow() throws IOException {
+ if (mBufferReason == BUFFER_REASON_RECORDING) {
+ // Recording does not need to stop because I/O speed is slow temporarily.
+ // If fixed size buffer of TsStreamer overflows, TsDataSource will reach EoS.
+ // Reaching EoS will stop recording eventually.
+ Log.w(
+ TAG,
+ "Disk I/O speed is slow for recording temporarily: "
+ + mBufferManager.getWriteBandwidth()
+ + "MBps");
+ return;
+ }
+ // Disables buffering samples afterwards, and notifies the disk speed is slow.
+ Log.w(TAG, "Disk is too slow for trickplay");
+ mBufferListener.onDiskTooSlow();
+ }
+
+ @Override
+ public void setEos() {
+ mSampleChunkIoHelper.closeWrite();
+ }
+
+ private boolean maybeReadSample(SampleQueue queue, int index) {
+ if (queue.getLastQueuedPositionUs() != null
+ && queue.getLastQueuedPositionUs() > mCurrentPlaybackPositionUs + BUFFER_NEEDED_US
+ && queue.isDurationGreaterThan(MIN_SEEK_DURATION_US)) {
+ // The speed of queuing samples can be higher than the playback speed.
+ // If the duration of the samples in the queue is not limited,
+ // samples can be accumulated and there can be out-of-memory issues.
+ // But, the throttling should provide enough samples for the player to
+ // finish the buffering state.
+ return false;
+ }
+ SampleHolder sample = mSampleChunkIoHelper.readSample(index);
+ if (sample != null) {
+ queue.queueSample(sample);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder outSample) {
+ Assertions.checkState(mTrackSelected[track]);
+ maybeReadSample(mReadSampleQueues.get(track), track);
+ int result = mReadSampleQueues.get(track).dequeueSample(outSample);
+ if ((result != SampleSource.SAMPLE_READ && mEos) || mError) {
+ return SampleSource.END_OF_STREAM;
+ }
+ return result;
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (mTrackSelected[i]) {
+ mReadSampleQueues.get(i).clear();
+ mSampleChunkIoHelper.openRead(i, positionUs);
+ }
+ }
+ mLastBufferedPositionUs = positionUs;
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ Long result = null;
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mTrackSelected[i]) {
+ continue;
+ }
+ Long lastQueuedSamplePositionUs = mReadSampleQueues.get(i).getLastQueuedPositionUs();
+ if (lastQueuedSamplePositionUs == null) {
+ // No sample has been queued.
+ result = mLastBufferedPositionUs;
+ continue;
+ }
+ if (result == null || result > lastQueuedSamplePositionUs) {
+ result = lastQueuedSamplePositionUs;
+ }
+ }
+ if (result == null) {
+ return mLastBufferedPositionUs;
+ }
+ return (mLastBufferedPositionUs = result);
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ mCurrentPlaybackPositionUs = positionUs;
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mTrackSelected[i]) {
+ continue;
+ }
+ SampleQueue queue = mReadSampleQueues.get(i);
+ maybeReadSample(queue, i);
+ if (queue.getLastQueuedPositionUs() == null
+ || positionUs > queue.getLastQueuedPositionUs()) {
+ // No more buffered data.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void release() throws IOException {
+ if (mTrackCount <= 0) {
+ return;
+ }
+ if (mSampleChunkIoHelper != null) {
+ mSampleChunkIoHelper.release();
+ }
+ }
+
+ // onChunkEvictedListener
+ @Override
+ public void onChunkEvicted(String id, long createdTimeMs) {
+ if (mBufferListener != null) {
+ mBufferListener.onBufferStartTimeChanged(
+ createdTimeMs + TimeUnit.MICROSECONDS.toMillis(MIN_SEEK_DURATION_US));
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java
new file mode 100644
index 00000000..bf77a6eb
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java
@@ -0,0 +1,433 @@
+/*
+ * 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.exoplayer.buffer;
+
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+import com.google.android.exoplayer.SampleHolder;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.channels.FileChannel;
+
+/**
+ * {@link SampleChunk} stores samples into file and makes them available for read. Stored file = {
+ * Header, Sample } * N Header = sample size : int, sample flag : int, sample PTS in micro second :
+ * long
+ */
+public class SampleChunk {
+ private static final String TAG = "SampleChunk";
+ private static final boolean DEBUG = false;
+
+ private final long mCreatedTimeMs;
+ private final long mStartPositionUs;
+ private SampleChunk mNextChunk;
+
+ // Header = sample size : int, sample flag : int, sample PTS in micro second : long
+ private static final int SAMPLE_HEADER_LENGTH = 16;
+
+ private final File mFile;
+ private final ChunkCallback mChunkCallback;
+ private final SamplePool mSamplePool;
+ private RandomAccessFile mAccessFile;
+ private long mWriteOffset;
+ private boolean mWriteFinished;
+ private boolean mIsReading;
+ private boolean mIsWriting;
+
+ /** A callback for chunks being committed to permanent storage. */
+ public abstract static class ChunkCallback {
+
+ /**
+ * Notifies when writing a SampleChunk is completed.
+ *
+ * @param chunk SampleChunk which is written completely
+ */
+ public void onChunkWrite(SampleChunk chunk) {}
+
+ /**
+ * Notifies when a SampleChunk is deleted.
+ *
+ * @param chunk SampleChunk which is deleted from storage
+ */
+ public void onChunkDelete(SampleChunk chunk) {}
+ }
+
+ /** A class for SampleChunk creation. */
+ public static class SampleChunkCreator {
+
+ /**
+ * Returns a newly created SampleChunk to read & write samples.
+ *
+ * @param samplePool sample allocator
+ * @param file filename which will be created newly
+ * @param startPositionUs the start position of the earliest sample to be stored
+ * @param chunkCallback for total storage usage change notification
+ */
+ @VisibleForTesting
+ public SampleChunk createSampleChunk(
+ SamplePool samplePool,
+ File file,
+ long startPositionUs,
+ ChunkCallback chunkCallback) {
+ return new SampleChunk(
+ samplePool, file, startPositionUs, System.currentTimeMillis(), chunkCallback);
+ }
+
+ /**
+ * Returns a newly created SampleChunk which is backed by an existing file. Created
+ * SampleChunk is read-only.
+ *
+ * @param samplePool sample allocator
+ * @param bufferDir the directory where the file to read is located
+ * @param filename the filename which will be read afterwards
+ * @param startPositionUs the start position of the earliest sample in the file
+ * @param chunkCallback for total storage usage change notification
+ * @param prev the previous SampleChunk just before the newly created SampleChunk
+ * @throws IOException
+ */
+ SampleChunk loadSampleChunkFromFile(
+ SamplePool samplePool,
+ File bufferDir,
+ String filename,
+ long startPositionUs,
+ ChunkCallback chunkCallback,
+ SampleChunk prev)
+ throws IOException {
+ File file = new File(bufferDir, filename);
+ SampleChunk chunk = new SampleChunk(samplePool, file, startPositionUs, chunkCallback);
+ if (prev != null) {
+ prev.mNextChunk = chunk;
+ }
+ return chunk;
+ }
+ }
+
+ /**
+ * Handles I/O for SampleChunk. Maintains current SampleChunk and the current offset for next
+ * I/O operation.
+ */
+ @VisibleForTesting
+ public static class IoState {
+ private SampleChunk mChunk;
+ private long mCurrentOffset;
+
+ private boolean equals(SampleChunk chunk, long offset) {
+ return chunk == mChunk && mCurrentOffset == offset;
+ }
+
+ /** Returns whether read I/O operation is finished. */
+ boolean isReadFinished() {
+ return mChunk == null;
+ }
+
+ /** Returns the start position of the current SampleChunk */
+ long getStartPositionUs() {
+ return mChunk == null ? 0 : mChunk.getStartPositionUs();
+ }
+
+ private void reset(@Nullable SampleChunk chunk) {
+ mChunk = chunk;
+ mCurrentOffset = 0;
+ }
+
+ private void reset(SampleChunk chunk, long offset) {
+ mChunk = chunk;
+ mCurrentOffset = offset;
+ }
+
+ /**
+ * Prepares for read I/O operation from a new SampleChunk.
+ *
+ * @param chunk the new SampleChunk to read from
+ * @throws IOException
+ */
+ void openRead(SampleChunk chunk, long offset) throws IOException {
+ if (mChunk != null) {
+ mChunk.closeRead();
+ }
+ chunk.openRead();
+ reset(chunk, offset);
+ }
+
+ /**
+ * Prepares for write I/O operation to a new SampleChunk.
+ *
+ * @param chunk the new SampleChunk to write samples afterwards
+ * @throws IOException
+ */
+ void openWrite(SampleChunk chunk) throws IOException {
+ if (mChunk != null) {
+ mChunk.closeWrite(chunk);
+ }
+ chunk.openWrite();
+ reset(chunk);
+ }
+
+ /**
+ * Reads a sample if it is available.
+ *
+ * @return Returns a sample if it is available, null otherwise.
+ * @throws IOException
+ */
+ SampleHolder read() throws IOException {
+ if (mChunk != null && mChunk.isReadFinished(this)) {
+ SampleChunk next = mChunk.mNextChunk;
+ mChunk.closeRead();
+ if (next != null) {
+ next.openRead();
+ }
+ reset(next);
+ }
+ if (mChunk != null) {
+ try {
+ return mChunk.read(this);
+ } catch (IllegalStateException e) {
+ // Write is finished and there is no additional buffer to read.
+ Log.w(TAG, "Tried to read sample over EOS.");
+ return null;
+ }
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Writes a sample.
+ *
+ * @param sample to write
+ * @param nextChunk if this is {@code null} writes at the current SampleChunk, otherwise
+ * close current SampleChunk and writes at this
+ * @throws IOException
+ */
+ void write(SampleHolder sample, SampleChunk nextChunk) throws IOException {
+ if (mChunk == null) {
+ throw new IOException("mChunk should not be null");
+ }
+ if (nextChunk != null) {
+ if (mChunk.mNextChunk != null) {
+ throw new IllegalStateException("Requested write for wrong SampleChunk");
+ }
+ mChunk.closeWrite(nextChunk);
+ mChunk.mChunkCallback.onChunkWrite(mChunk);
+ nextChunk.openWrite();
+ reset(nextChunk);
+ }
+ mChunk.write(sample, this);
+ }
+
+ /**
+ * Finishes write I/O operation.
+ *
+ * @throws IOException
+ */
+ void closeWrite() throws IOException {
+ if (mChunk != null) {
+ mChunk.closeWrite(null);
+ }
+ }
+
+ /** Returns the current SampleChunk for subsequent I/O operation. */
+ SampleChunk getChunk() {
+ return mChunk;
+ }
+
+ /** Returns the current offset of the current SampleChunk for subsequent I/O operation. */
+ long getOffset() {
+ return mCurrentOffset;
+ }
+
+ /**
+ * Releases SampleChunk. the SampleChunk will not be used anymore.
+ *
+ * @param chunk to release
+ * @param delete {@code true} when the backed file needs to be deleted, {@code false}
+ * otherwise.
+ */
+ static void release(SampleChunk chunk, boolean delete) {
+ chunk.release(delete);
+ }
+ }
+
+ @VisibleForTesting
+ protected SampleChunk(
+ SamplePool samplePool,
+ File file,
+ long startPositionUs,
+ long createdTimeMs,
+ ChunkCallback chunkCallback) {
+ mStartPositionUs = startPositionUs;
+ mCreatedTimeMs = createdTimeMs;
+ mSamplePool = samplePool;
+ mFile = file;
+ mChunkCallback = chunkCallback;
+ }
+
+ // Constructor of SampleChunk which is backed by the given existing file.
+ private SampleChunk(
+ SamplePool samplePool, File file, long startPositionUs, ChunkCallback chunkCallback)
+ throws IOException {
+ mStartPositionUs = startPositionUs;
+ mCreatedTimeMs = mStartPositionUs / 1000;
+ mSamplePool = samplePool;
+ mFile = file;
+ mChunkCallback = chunkCallback;
+ mWriteFinished = true;
+ }
+
+ private void openRead() throws IOException {
+ if (!mIsReading) {
+ if (mAccessFile == null) {
+ mAccessFile = new RandomAccessFile(mFile, "r");
+ }
+ if (mWriteFinished && mWriteOffset == 0) {
+ // Lazy loading of write offset, in order not to load
+ // all SampleChunk's write offset at start time of recorded playback.
+ mWriteOffset = mAccessFile.length();
+ }
+ mIsReading = true;
+ }
+ }
+
+ private void openWrite() throws IOException {
+ if (mWriteFinished) {
+ throw new IllegalStateException("Opened for write though write is already finished");
+ }
+ if (!mIsWriting) {
+ if (mIsReading) {
+ throw new IllegalStateException(
+ "Write is requested for " + "an already opened SampleChunk");
+ }
+ mAccessFile = new RandomAccessFile(mFile, "rw");
+ mIsWriting = true;
+ }
+ }
+
+ private void CloseAccessFileIfNeeded() throws IOException {
+ if (!mIsReading && !mIsWriting) {
+ try {
+ if (mAccessFile != null) {
+ mAccessFile.close();
+ }
+ } finally {
+ mAccessFile = null;
+ }
+ }
+ }
+
+ private void closeRead() throws IOException {
+ if (mIsReading) {
+ mIsReading = false;
+ CloseAccessFileIfNeeded();
+ }
+ }
+
+ private void closeWrite(SampleChunk nextChunk) throws IOException {
+ if (mIsWriting) {
+ mNextChunk = nextChunk;
+ mIsWriting = false;
+ mWriteFinished = true;
+ CloseAccessFileIfNeeded();
+ }
+ }
+
+ private boolean isReadFinished(IoState state) {
+ return mWriteFinished && state.equals(this, mWriteOffset);
+ }
+
+ private SampleHolder read(IoState state) throws IOException {
+ if (mAccessFile == null || state.mChunk != this) {
+ throw new IllegalStateException("Requested read for wrong SampleChunk");
+ }
+ long offset = state.mCurrentOffset;
+ if (offset >= mWriteOffset) {
+ if (mWriteFinished) {
+ throw new IllegalStateException("Requested read for wrong range");
+ } else {
+ if (offset != mWriteOffset) {
+ Log.e(TAG, "This should not happen!");
+ }
+ return null;
+ }
+ }
+ mAccessFile.seek(offset);
+ int size = mAccessFile.readInt();
+ SampleHolder sample = mSamplePool.acquireSample(size);
+ sample.size = size;
+ sample.flags = mAccessFile.readInt();
+ sample.timeUs = mAccessFile.readLong();
+ sample.clearData();
+ sample.data.put(
+ mAccessFile
+ .getChannel()
+ .map(
+ FileChannel.MapMode.READ_ONLY,
+ offset + SAMPLE_HEADER_LENGTH,
+ sample.size));
+ offset += sample.size + SAMPLE_HEADER_LENGTH;
+ state.mCurrentOffset = offset;
+ return sample;
+ }
+
+ @VisibleForTesting
+ protected void write(SampleHolder sample, IoState state) throws IOException {
+ if (mAccessFile == null || mNextChunk != null || !state.equals(this, mWriteOffset)) {
+ throw new IllegalStateException("Requested write for wrong SampleChunk");
+ }
+
+ mAccessFile.seek(mWriteOffset);
+ mAccessFile.writeInt(sample.size);
+ mAccessFile.writeInt(sample.flags);
+ mAccessFile.writeLong(sample.timeUs);
+ sample.data.position(0).limit(sample.size);
+ mAccessFile.getChannel().position(mWriteOffset + SAMPLE_HEADER_LENGTH).write(sample.data);
+ mWriteOffset += sample.size + SAMPLE_HEADER_LENGTH;
+ state.mCurrentOffset = mWriteOffset;
+ }
+
+ private void release(boolean delete) {
+ mWriteFinished = true;
+ mIsReading = mIsWriting = false;
+ try {
+ if (mAccessFile != null) {
+ mAccessFile.close();
+ }
+ } catch (IOException e) {
+ // Since the SampleChunk will not be reused, ignore exception.
+ }
+ if (delete) {
+ mFile.delete();
+ mChunkCallback.onChunkDelete(this);
+ }
+ }
+
+ /** Returns the start position. */
+ public long getStartPositionUs() {
+ return mStartPositionUs;
+ }
+
+ /** Returns the creation time. */
+ public long getCreatedTimeMs() {
+ return mCreatedTimeMs;
+ }
+
+ /** Returns the current size. */
+ public long getSize() {
+ return mWriteOffset;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java
new file mode 100644
index 00000000..d95d0adb
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java
@@ -0,0 +1,464 @@
+/*
+ * 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.exoplayer.buffer;
+
+import android.media.MediaCodec;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Pair;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer.BufferReason;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * Handles all {@link SampleChunk} I/O operations. An I/O dedicated thread handles all I/O
+ * operations for synchronization.
+ */
+public class SampleChunkIoHelper implements Handler.Callback {
+ private static final String TAG = "SampleChunkIoHelper";
+
+ private static final int MAX_READ_BUFFER_SAMPLES = 3;
+ private static final int READ_RESCHEDULING_DELAY_MS = 10;
+
+ private static final int MSG_OPEN_READ = 1;
+ private static final int MSG_OPEN_WRITE = 2;
+ private static final int MSG_CLOSE_READ = 3;
+ private static final int MSG_CLOSE_WRITE = 4;
+ private static final int MSG_READ = 5;
+ private static final int MSG_WRITE = 6;
+ private static final int MSG_RELEASE = 7;
+
+ private final long mSampleChunkDurationUs;
+ private final int mTrackCount;
+ private final List<String> mIds;
+ private final List<MediaFormat> mMediaFormats;
+ private final @BufferReason int mBufferReason;
+ private final BufferManager mBufferManager;
+ private final SamplePool mSamplePool;
+ private final IoCallback mIoCallback;
+
+ private Handler mIoHandler;
+ private final ConcurrentLinkedQueue<SampleHolder> mReadSampleBuffers[];
+ private final ConcurrentLinkedQueue<SampleHolder> mHandlerReadSampleBuffers[];
+ private final long[] mWriteIndexEndPositionUs;
+ private final long[] mWriteChunkEndPositionUs;
+ private final SampleChunk.IoState[] mReadIoStates;
+ private final SampleChunk.IoState[] mWriteIoStates;
+ private final Set<Integer> mSelectedTracks = new ArraySet<>();
+ private long mBufferDurationUs = 0;
+ private boolean mWriteEnded;
+ private boolean mErrorNotified;
+ private boolean mFinished;
+
+ /** A Callback for I/O events. */
+ public abstract static class IoCallback {
+
+ /** Called when there is no sample to read. */
+ public void onIoReachedEos() {}
+
+ /** Called when there is an irrecoverable error during I/O. */
+ public void onIoError() {}
+ }
+
+ private static class IoParams {
+ private final int index;
+ private final long positionUs;
+ private final SampleHolder sample;
+ private final ConditionVariable conditionVariable;
+ private final ConcurrentLinkedQueue<SampleHolder> readSampleBuffer;
+
+ private IoParams(
+ int index,
+ long positionUs,
+ SampleHolder sample,
+ ConditionVariable conditionVariable,
+ ConcurrentLinkedQueue<SampleHolder> readSampleBuffer) {
+ this.index = index;
+ this.positionUs = positionUs;
+ this.sample = sample;
+ this.conditionVariable = conditionVariable;
+ this.readSampleBuffer = readSampleBuffer;
+ }
+ }
+
+ /**
+ * Creates {@link SampleChunk} I/O handler.
+ *
+ * @param ids track names
+ * @param mediaFormats {@link android.media.MediaFormat} for each track
+ * @param bufferReason reason to be buffered
+ * @param bufferManager manager of {@link SampleChunk} collections
+ * @param samplePool allocator for a sample
+ * @param ioCallback listeners for I/O events
+ */
+ public SampleChunkIoHelper(
+ List<String> ids,
+ List<MediaFormat> mediaFormats,
+ @BufferReason int bufferReason,
+ BufferManager bufferManager,
+ SamplePool samplePool,
+ IoCallback ioCallback) {
+ mTrackCount = ids.size();
+ mIds = ids;
+ mMediaFormats = mediaFormats;
+ mBufferReason = bufferReason;
+ mBufferManager = bufferManager;
+ mSamplePool = samplePool;
+ mIoCallback = ioCallback;
+
+ mReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount];
+ mHandlerReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount];
+ mWriteIndexEndPositionUs = new long[mTrackCount];
+ mWriteChunkEndPositionUs = new long[mTrackCount];
+ mReadIoStates = new SampleChunk.IoState[mTrackCount];
+ mWriteIoStates = new SampleChunk.IoState[mTrackCount];
+
+ // Small chunk duration for live playback will give more fine grained storage usage
+ // and eviction handling for trickplay.
+ mSampleChunkDurationUs =
+ bufferReason == RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK
+ ? RecordingSampleBuffer.MIN_SEEK_DURATION_US
+ : RecordingSampleBuffer.RECORDING_CHUNK_DURATION_US;
+ for (int i = 0; i < mTrackCount; ++i) {
+ mWriteIndexEndPositionUs[i] = RecordingSampleBuffer.MIN_SEEK_DURATION_US;
+ mWriteChunkEndPositionUs[i] = mSampleChunkDurationUs;
+ mReadIoStates[i] = new SampleChunk.IoState();
+ mWriteIoStates[i] = new SampleChunk.IoState();
+ }
+ }
+
+ /**
+ * Prepares and initializes for I/O operations.
+ *
+ * @throws IOException
+ */
+ public void init() throws IOException {
+ HandlerThread handlerThread = new HandlerThread(TAG);
+ handlerThread.start();
+ mIoHandler = new Handler(handlerThread.getLooper(), this);
+ if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ mBufferManager.loadTrackFromStorage(mIds.get(i), mSamplePool);
+ }
+ mWriteEnded = true;
+ } else {
+ for (int i = 0; i < mTrackCount; ++i) {
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_OPEN_WRITE, i));
+ }
+ }
+ }
+
+ /**
+ * Reads a sample if it is available.
+ *
+ * @param index track index
+ * @return {@code null} if a sample is not available, otherwise returns a sample
+ */
+ public SampleHolder readSample(int index) {
+ SampleHolder sample = mReadSampleBuffers[index].poll();
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_READ, index));
+ return sample;
+ }
+
+ /**
+ * Writes a sample.
+ *
+ * @param index track index
+ * @param sample to write
+ * @param conditionVariable which will be wait until the write is finished
+ * @throws IOException
+ */
+ public void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException {
+ if (mErrorNotified) {
+ throw new IOException("Storage I/O error happened");
+ }
+ conditionVariable.close();
+ IoParams params = new IoParams(index, 0, sample, conditionVariable, null);
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_WRITE, params));
+ }
+
+ /**
+ * Starts read from the specified position.
+ *
+ * @param index track index
+ * @param positionUs the specified position
+ */
+ public void openRead(int index, long positionUs) {
+ // Old mReadSampleBuffers may have a pending read.
+ mReadSampleBuffers[index] = new ConcurrentLinkedQueue<>();
+ IoParams params = new IoParams(index, positionUs, null, null, mReadSampleBuffers[index]);
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_OPEN_READ, params));
+ }
+
+ /**
+ * Closes read from the specified track.
+ *
+ * @param index track index
+ */
+ public void closeRead(int index) {
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_CLOSE_READ, index));
+ }
+
+ /** Notifies writes are finished. */
+ public void closeWrite() {
+ mIoHandler.sendEmptyMessage(MSG_CLOSE_WRITE);
+ }
+
+ /**
+ * Finishes I/O operations and releases all the resources.
+ *
+ * @throws IOException
+ */
+ public void release() throws IOException {
+ if (mIoHandler == null) {
+ return;
+ }
+ // Finishes all I/O operations.
+ ConditionVariable conditionVariable = new ConditionVariable();
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_RELEASE, conditionVariable));
+ conditionVariable.block();
+
+ for (int i = 0; i < mTrackCount; ++i) {
+ mBufferManager.unregisterChunkEvictedListener(mIds.get(i));
+ }
+ try {
+ if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDING && mTrackCount > 0) {
+ // Saves meta information for recording.
+ List<BufferManager.TrackFormat> audios = new LinkedList<>();
+ List<BufferManager.TrackFormat> videos = new LinkedList<>();
+ for (int i = 0; i < mTrackCount; ++i) {
+ android.media.MediaFormat format =
+ mMediaFormats.get(i).getFrameworkMediaFormatV16();
+ format.setLong(android.media.MediaFormat.KEY_DURATION, mBufferDurationUs);
+ if (MimeTypes.isAudio(mMediaFormats.get(i).mimeType)) {
+ audios.add(new BufferManager.TrackFormat(mIds.get(i), format));
+ } else if (MimeTypes.isVideo(mMediaFormats.get(i).mimeType)) {
+ videos.add(new BufferManager.TrackFormat(mIds.get(i), format));
+ }
+ }
+ mBufferManager.writeMetaFiles(audios, videos);
+ }
+ } finally {
+ mBufferManager.release();
+ mIoHandler.getLooper().quitSafely();
+ }
+ }
+
+ @Override
+ public boolean handleMessage(Message message) {
+ if (mFinished) {
+ return true;
+ }
+ releaseEvictedChunks();
+ try {
+ switch (message.what) {
+ case MSG_OPEN_READ:
+ doOpenRead((IoParams) message.obj);
+ return true;
+ case MSG_OPEN_WRITE:
+ doOpenWrite((int) message.obj);
+ return true;
+ case MSG_CLOSE_READ:
+ doCloseRead((int) message.obj);
+ return true;
+ case MSG_CLOSE_WRITE:
+ doCloseWrite();
+ return true;
+ case MSG_READ:
+ doRead((int) message.obj);
+ return true;
+ case MSG_WRITE:
+ doWrite((IoParams) message.obj);
+ // Since only write will increase storage, eviction will be handled here.
+ return true;
+ case MSG_RELEASE:
+ doRelease((ConditionVariable) message.obj);
+ return true;
+ }
+ } catch (IOException e) {
+ mIoCallback.onIoError();
+ mErrorNotified = true;
+ Log.e(TAG, "IoException happened", e);
+ return true;
+ }
+ return false;
+ }
+
+ private void doOpenRead(IoParams params) throws IOException {
+ int index = params.index;
+ mIoHandler.removeMessages(MSG_READ, index);
+ Pair<SampleChunk, Integer> readPosition =
+ mBufferManager.getReadFile(mIds.get(index), params.positionUs);
+ if (readPosition == null) {
+ String errorMessage =
+ "Chunk ID:" + mIds.get(index) + " pos:" + params.positionUs + "is not found";
+ SoftPreconditions.checkNotNull(readPosition, TAG, errorMessage);
+ throw new IOException(errorMessage);
+ }
+ mSelectedTracks.add(index);
+ mReadIoStates[index].openRead(readPosition.first, (long) readPosition.second);
+ if (mHandlerReadSampleBuffers[index] != null) {
+ SampleHolder sample;
+ while ((sample = mHandlerReadSampleBuffers[index].poll()) != null) {
+ mSamplePool.releaseSample(sample);
+ }
+ }
+ mHandlerReadSampleBuffers[index] = params.readSampleBuffer;
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_READ, index));
+ }
+
+ private void doOpenWrite(int index) throws IOException {
+ SampleChunk chunk =
+ mBufferManager.createNewWriteFileIfNeeded(mIds.get(index), 0, mSamplePool, null, 0);
+ mWriteIoStates[index].openWrite(chunk);
+ }
+
+ private void doCloseRead(int index) {
+ mSelectedTracks.remove(index);
+ if (mHandlerReadSampleBuffers[index] != null) {
+ SampleHolder sample;
+ while ((sample = mHandlerReadSampleBuffers[index].poll()) != null) {
+ mSamplePool.releaseSample(sample);
+ }
+ }
+ mIoHandler.removeMessages(MSG_READ, index);
+ }
+
+ private void doRead(int index) throws IOException {
+ mIoHandler.removeMessages(MSG_READ, index);
+ if (mHandlerReadSampleBuffers[index].size() >= MAX_READ_BUFFER_SAMPLES) {
+ // If enough samples are buffered, try again few moments later hoping that
+ // buffered samples are consumed.
+ mIoHandler.sendMessageDelayed(
+ mIoHandler.obtainMessage(MSG_READ, index), READ_RESCHEDULING_DELAY_MS);
+ } else {
+ if (mReadIoStates[index].isReadFinished()) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mReadIoStates[i].isReadFinished()) {
+ return;
+ }
+ }
+ mIoCallback.onIoReachedEos();
+ return;
+ }
+ SampleHolder sample = mReadIoStates[index].read();
+ if (sample != null) {
+ mHandlerReadSampleBuffers[index].offer(sample);
+ } else {
+ // Read reached write but write is not finished yet --- wait a few moments to
+ // see if another sample is written.
+ mIoHandler.sendMessageDelayed(
+ mIoHandler.obtainMessage(MSG_READ, index), READ_RESCHEDULING_DELAY_MS);
+ }
+ }
+ }
+
+ private void doWrite(IoParams params) throws IOException {
+ try {
+ if (mWriteEnded) {
+ SoftPreconditions.checkState(false);
+ return;
+ }
+ int index = params.index;
+ SampleHolder sample = params.sample;
+ SampleChunk nextChunk = null;
+ if ((sample.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
+ if (sample.timeUs > mBufferDurationUs) {
+ mBufferDurationUs = sample.timeUs;
+ }
+ if (sample.timeUs >= mWriteIndexEndPositionUs[index]) {
+ SampleChunk currentChunk =
+ sample.timeUs >= mWriteChunkEndPositionUs[index]
+ ? null
+ : mWriteIoStates[params.index].getChunk();
+ int currentOffset = (int) mWriteIoStates[params.index].getOffset();
+ nextChunk =
+ mBufferManager.createNewWriteFileIfNeeded(
+ mIds.get(index),
+ mWriteIndexEndPositionUs[index],
+ mSamplePool,
+ currentChunk,
+ currentOffset);
+ mWriteIndexEndPositionUs[index] =
+ ((sample.timeUs / RecordingSampleBuffer.MIN_SEEK_DURATION_US) + 1)
+ * RecordingSampleBuffer.MIN_SEEK_DURATION_US;
+ if (nextChunk != null) {
+ mWriteChunkEndPositionUs[index] =
+ ((sample.timeUs / mSampleChunkDurationUs) + 1)
+ * mSampleChunkDurationUs;
+ }
+ }
+ }
+ mWriteIoStates[params.index].write(params.sample, nextChunk);
+ } finally {
+ params.conditionVariable.open();
+ }
+ }
+
+ private void doCloseWrite() throws IOException {
+ if (mWriteEnded) {
+ return;
+ }
+ mWriteEnded = true;
+ boolean readFinished = true;
+ for (int i = 0; i < mTrackCount; ++i) {
+ readFinished = readFinished && mReadIoStates[i].isReadFinished();
+ mWriteIoStates[i].closeWrite();
+ }
+ if (readFinished) {
+ mIoCallback.onIoReachedEos();
+ }
+ }
+
+ private void doRelease(ConditionVariable conditionVariable) {
+ mIoHandler.removeCallbacksAndMessages(null);
+ mFinished = true;
+ conditionVariable.open();
+ mSelectedTracks.clear();
+ }
+
+ private void releaseEvictedChunks() {
+ if (mBufferReason != RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK
+ || mSelectedTracks.isEmpty()) {
+ return;
+ }
+ long currentStartPositionUs = Long.MAX_VALUE;
+ for (int trackIndex : mSelectedTracks) {
+ currentStartPositionUs =
+ Math.min(
+ currentStartPositionUs, mReadIoStates[trackIndex].getStartPositionUs());
+ }
+ for (int i = 0; i < mTrackCount; ++i) {
+ long evictEndPositionUs =
+ Math.min(
+ mBufferManager.getStartPositionUs(mIds.get(i)), currentStartPositionUs);
+ mBufferManager.evictChunks(mIds.get(i), evictEndPositionUs);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java
new file mode 100644
index 00000000..b89a14db
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java
@@ -0,0 +1,67 @@
+/*
+ * 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.exoplayer.buffer;
+
+import com.google.android.exoplayer.SampleHolder;
+import java.util.LinkedList;
+
+/** Pool of samples to recycle ByteBuffers as much as possible. */
+public class SamplePool {
+ private final LinkedList<SampleHolder> mSamplePool = new LinkedList<>();
+
+ /**
+ * Acquires a sample with a buffer larger than size from the pool. Allocate new one or resize an
+ * existing buffer if necessary.
+ */
+ public synchronized SampleHolder acquireSample(int size) {
+ if (mSamplePool.isEmpty()) {
+ SampleHolder sample = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL);
+ sample.ensureSpaceForWrite(size);
+ return sample;
+ }
+ SampleHolder smallestSufficientSample = null;
+ SampleHolder maxSample = mSamplePool.getFirst();
+ for (SampleHolder sample : mSamplePool) {
+ // Grab the smallest sufficient sample.
+ if (sample.data.capacity() >= size
+ && (smallestSufficientSample == null
+ || smallestSufficientSample.data.capacity() > sample.data.capacity())) {
+ smallestSufficientSample = sample;
+ }
+
+ // Grab the max size sample.
+ if (maxSample.data.capacity() < sample.data.capacity()) {
+ maxSample = sample;
+ }
+ }
+ SampleHolder sampleFromPool = smallestSufficientSample;
+
+ // If there's no sufficient sample, grab the maximum sample and resize it to size.
+ if (sampleFromPool == null) {
+ sampleFromPool = maxSample;
+ sampleFromPool.ensureSpaceForWrite(size);
+ }
+ mSamplePool.remove(sampleFromPool);
+ return sampleFromPool;
+ }
+
+ /** Releases the sample back to the pool. */
+ public synchronized void releaseSample(SampleHolder sample) {
+ sample.clearData();
+ mSamplePool.offerLast(sample);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java
new file mode 100644
index 00000000..e208f2c2
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java
@@ -0,0 +1,72 @@
+/*
+ * 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.exoplayer.buffer;
+
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import java.util.LinkedList;
+
+/** A sample queue which reads from the buffer and passes to player pipeline. */
+public class SampleQueue {
+ private final LinkedList<SampleHolder> mQueue = new LinkedList<>();
+ private final SamplePool mSamplePool;
+ private Long mLastQueuedPositionUs = null;
+
+ public SampleQueue(SamplePool samplePool) {
+ mSamplePool = samplePool;
+ }
+
+ public void queueSample(SampleHolder sample) {
+ mQueue.offer(sample);
+ mLastQueuedPositionUs = sample.timeUs;
+ }
+
+ public int dequeueSample(SampleHolder sample) {
+ SampleHolder sampleFromQueue = mQueue.poll();
+ if (sampleFromQueue == null) {
+ return SampleSource.NOTHING_READ;
+ }
+ sample.ensureSpaceForWrite(sampleFromQueue.size);
+ sample.size = sampleFromQueue.size;
+ sample.flags = sampleFromQueue.flags;
+ sample.timeUs = sampleFromQueue.timeUs;
+ sample.clearData();
+ sampleFromQueue.data.position(0).limit(sample.size);
+ sample.data.put(sampleFromQueue.data);
+ mSamplePool.releaseSample(sampleFromQueue);
+ return SampleSource.SAMPLE_READ;
+ }
+
+ public void clear() {
+ while (!mQueue.isEmpty()) {
+ mSamplePool.releaseSample(mQueue.poll());
+ }
+ mLastQueuedPositionUs = null;
+ }
+
+ public Long getLastQueuedPositionUs() {
+ return mLastQueuedPositionUs;
+ }
+
+ public boolean isDurationGreaterThan(long durationUs) {
+ return !mQueue.isEmpty() && mQueue.getLast().timeUs - mQueue.getFirst().timeUs > durationUs;
+ }
+
+ public boolean isEmpty() {
+ return mQueue.isEmpty();
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java
new file mode 100644
index 00000000..4c6260bf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java
@@ -0,0 +1,177 @@
+/*
+ * 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.exoplayer.buffer;
+
+import android.os.ConditionVariable;
+import android.support.annotation.NonNull;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Handles I/O for {@link SampleExtractor} when physical storage based buffer is not used. Trickplay
+ * is disabled.
+ */
+public class SimpleSampleBuffer implements BufferManager.SampleBuffer {
+ private final SamplePool mSamplePool = new SamplePool();
+ private SampleQueue[] mPlayingSampleQueues;
+ private long mLastBufferedPositionUs = C.UNKNOWN_TIME_US;
+
+ private volatile boolean mEos;
+
+ public SimpleSampleBuffer(PlaybackBufferListener bufferListener) {
+ if (bufferListener != null) {
+ // Disables trickplay.
+ bufferListener.onBufferStateChanged(false);
+ }
+ }
+
+ @Override
+ public synchronized void init(
+ @NonNull List<String> ids, @NonNull List<MediaFormat> mediaFormats) {
+ int trackCount = ids.size();
+ mPlayingSampleQueues = new SampleQueue[trackCount];
+ for (int i = 0; i < trackCount; i++) {
+ mPlayingSampleQueues[i] = null;
+ }
+ }
+
+ @Override
+ public void setEos() {
+ mEos = true;
+ }
+
+ private boolean reachedEos() {
+ return mEos;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] == null) {
+ mPlayingSampleQueues[index] = new SampleQueue(mSamplePool);
+ } else {
+ mPlayingSampleQueues[index].clear();
+ }
+ }
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] != null) {
+ mPlayingSampleQueues[index].clear();
+ mPlayingSampleQueues[index] = null;
+ }
+ }
+ }
+
+ @Override
+ public synchronized long getBufferedPositionUs() {
+ Long result = null;
+ for (SampleQueue queue : mPlayingSampleQueues) {
+ if (queue == null) {
+ continue;
+ }
+ Long lastQueuedSamplePositionUs = queue.getLastQueuedPositionUs();
+ if (lastQueuedSamplePositionUs == null) {
+ // No sample has been queued.
+ result = mLastBufferedPositionUs;
+ continue;
+ }
+ if (result == null || result > lastQueuedSamplePositionUs) {
+ result = lastQueuedSamplePositionUs;
+ }
+ }
+ if (result == null) {
+ return mLastBufferedPositionUs;
+ }
+ return (mLastBufferedPositionUs = result);
+ }
+
+ @Override
+ public synchronized int readSample(int track, SampleHolder sampleHolder) {
+ SampleQueue queue = mPlayingSampleQueues[track];
+ SoftPreconditions.checkNotNull(queue);
+ int result = queue == null ? SampleSource.NOTHING_READ : queue.dequeueSample(sampleHolder);
+ if (result != SampleSource.SAMPLE_READ && reachedEos()) {
+ return SampleSource.END_OF_STREAM;
+ }
+ return result;
+ }
+
+ @Override
+ public void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException {
+ sample.data.position(0).limit(sample.size);
+ SampleHolder sampleToQueue = mSamplePool.acquireSample(sample.size);
+ sampleToQueue.size = sample.size;
+ sampleToQueue.clearData();
+ sampleToQueue.data.put(sample.data);
+ sampleToQueue.timeUs = sample.timeUs;
+ sampleToQueue.flags = sample.flags;
+
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] != null) {
+ mPlayingSampleQueues[index].queueSample(sampleToQueue);
+ }
+ }
+ }
+
+ @Override
+ public boolean isWriteSpeedSlow(int sampleSize, long durationNs) {
+ // Since SimpleSampleBuffer write samples only to memory (not to physical storage),
+ // write speed is always fine.
+ return false;
+ }
+
+ @Override
+ public void handleWriteSpeedSlow() {
+ // no-op
+ }
+
+ @Override
+ public synchronized boolean continueBuffering(long positionUs) {
+ for (SampleQueue queue : mPlayingSampleQueues) {
+ if (queue == null) {
+ continue;
+ }
+ if (queue.getLastQueuedPositionUs() == null
+ || positionUs > queue.getLastQueuedPositionUs()) {
+ // No more buffered data.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ // Not used.
+ }
+
+ @Override
+ public void release() {
+ // Not used.
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java
new file mode 100644
index 00000000..b22b8af1
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java
@@ -0,0 +1,145 @@
+/*
+ * 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.exoplayer.buffer;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.provider.Settings;
+import android.support.annotation.NonNull;
+import android.util.Pair;
+import com.android.tv.common.SoftPreconditions;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.SortedMap;
+
+/** Manages Trickplay storage. */
+public class TrickplayStorageManager implements BufferManager.StorageManager {
+ // TODO: Support multi-sessions.
+ private static final String BUFFER_DIR = "timeshift";
+
+ // Copied from android.provider.Settings.Global (hidden fields)
+ private static final String SYS_STORAGE_THRESHOLD_PERCENTAGE =
+ "sys_storage_threshold_percentage";
+ private static final String SYS_STORAGE_THRESHOLD_MAX_BYTES = "sys_storage_threshold_max_bytes";
+
+ // Copied from android.os.StorageManager
+ private static final int DEFAULT_THRESHOLD_PERCENTAGE = 10;
+ private static final long DEFAULT_THRESHOLD_MAX_BYTES = 500L * 1024 * 1024;
+
+ private static AsyncTask<Void, Void, Void> sLastCacheCleanUpTask;
+ private static File sBufferDir;
+ private static long sStorageBufferBytes;
+
+ private final long mMaxBufferSize;
+
+ private static void initParamsIfNeeded(Context context, @NonNull File path) {
+ // TODO: Support multi-sessions.
+ SoftPreconditions.checkState(sBufferDir == null || sBufferDir.equals(path));
+ if (path.equals(sBufferDir)) {
+ return;
+ }
+ sBufferDir = path;
+ long lowPercentage =
+ Settings.Global.getInt(
+ context.getContentResolver(),
+ SYS_STORAGE_THRESHOLD_PERCENTAGE,
+ DEFAULT_THRESHOLD_PERCENTAGE);
+ long lowPercentageToBytes = path.getTotalSpace() * lowPercentage / 100;
+ long maxLowBytes =
+ Settings.Global.getLong(
+ context.getContentResolver(),
+ SYS_STORAGE_THRESHOLD_MAX_BYTES,
+ DEFAULT_THRESHOLD_MAX_BYTES);
+ sStorageBufferBytes = Math.min(lowPercentageToBytes, maxLowBytes);
+ }
+
+ public TrickplayStorageManager(Context context, @NonNull File baseDir, long maxBufferSize) {
+ initParamsIfNeeded(context, new File(baseDir, BUFFER_DIR));
+ sBufferDir.mkdirs();
+ mMaxBufferSize = maxBufferSize;
+ clearStorage();
+ }
+
+ private void clearStorage() {
+ long now = System.currentTimeMillis();
+ if (sLastCacheCleanUpTask != null) {
+ sLastCacheCleanUpTask.cancel(true);
+ }
+ sLastCacheCleanUpTask =
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (isCancelled()) {
+ return null;
+ }
+ File files[] = sBufferDir.listFiles();
+ if (files == null || files.length == 0) {
+ return null;
+ }
+ for (File file : files) {
+ if (isCancelled()) {
+ break;
+ }
+ long lastModified = file.lastModified();
+ if (lastModified != 0 && lastModified < now) {
+ file.delete();
+ }
+ }
+ return null;
+ }
+ };
+ sLastCacheCleanUpTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ @Override
+ public File getBufferDir() {
+ return sBufferDir;
+ }
+
+ @Override
+ public boolean isPersistent() {
+ return false;
+ }
+
+ @Override
+ public boolean reachedStorageMax(long bufferSize, long pendingDelete) {
+ return bufferSize - pendingDelete > mMaxBufferSize;
+ }
+
+ @Override
+ public boolean hasEnoughBuffer(long pendingDelete) {
+ return sBufferDir.getUsableSpace() + pendingDelete >= sStorageBufferBytes;
+ }
+
+ @Override
+ public List<BufferManager.TrackFormat> readTrackInfoFiles(boolean isAudio) {
+ return null;
+ }
+
+ @Override
+ public ArrayList<BufferManager.PositionHolder> readIndexFile(String trackId) {
+ return null;
+ }
+
+ @Override
+ public void writeTrackInfoFiles(List<BufferManager.TrackFormat> formatList, boolean isAudio) {}
+
+ @Override
+ public void writeIndexFile(
+ String trackName, SortedMap<Long, Pair<SampleChunk, Integer>> index) {}
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/text/SubtitleView.java b/tuner/src/com/android/tv/tuner/exoplayer/text/SubtitleView.java
new file mode 100644
index 00000000..91bee7a0
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/text/SubtitleView.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright (C) 2014 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.exoplayer.text;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Paint.Join;
+import android.graphics.Paint.Style;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.text.Layout.Alignment;
+import android.text.StaticLayout;
+import android.text.TextPaint;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.View;
+import com.google.android.exoplayer.text.CaptionStyleCompat;
+import com.google.android.exoplayer.util.Util;
+import java.util.ArrayList;
+import java.util.Objects;
+
+/**
+ * Since this class does not exist in recent version of ExoPlayer and used by {@link
+ * com.android.tv.tuner.cc.CaptionWindowLayout}, this class is copied from older version of
+ * ExoPlayer. A view for rendering a single caption.
+ */
+@Deprecated
+public class SubtitleView extends View {
+ /** Ratio of inner padding to font size. */
+ private static final float INNER_PADDING_RATIO = 0.125f;
+
+ /** Temporary rectangle used for computing line bounds. */
+ private final RectF mLineBounds = new RectF();
+
+ // Styled dimensions.
+ private final float mCornerRadius;
+ private final float mOutlineWidth;
+ private final float mShadowRadius;
+ private final float mShadowOffset;
+
+ private final TextPaint mTextPaint;
+ private final Paint mPaint;
+
+ private CharSequence mText;
+
+ private int mForegroundColor;
+ private int mBackgroundColor;
+ private int mEdgeColor;
+ private int mEdgeType;
+
+ private boolean mHasMeasurements;
+ private int mLastMeasuredWidth;
+ private StaticLayout mLayout;
+
+ private Alignment mAlignment;
+ private final float mSpacingMult;
+ private final float mSpacingAdd;
+ private int mInnerPaddingX;
+ private float mWhiteSpaceWidth;
+ private ArrayList<Integer> mPrefixSpaces = new ArrayList<>();
+
+ public SubtitleView(Context context) {
+ this(context, null);
+ }
+
+ public SubtitleView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public SubtitleView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ int[] viewAttr = {
+ android.R.attr.text,
+ android.R.attr.textSize,
+ android.R.attr.lineSpacingExtra,
+ android.R.attr.lineSpacingMultiplier
+ };
+ TypedArray a = context.obtainStyledAttributes(attrs, viewAttr, defStyleAttr, 0);
+ CharSequence text = a.getText(0);
+ int textSize = a.getDimensionPixelSize(1, 15);
+ mSpacingAdd = a.getDimensionPixelSize(2, 0);
+ mSpacingMult = a.getFloat(3, 1);
+ a.recycle();
+
+ Resources resources = getContext().getResources();
+ DisplayMetrics displayMetrics = resources.getDisplayMetrics();
+ int twoDpInPx =
+ Math.round((2f * displayMetrics.densityDpi) / DisplayMetrics.DENSITY_DEFAULT);
+ mCornerRadius = twoDpInPx;
+ mOutlineWidth = twoDpInPx;
+ mShadowRadius = twoDpInPx;
+ mShadowOffset = twoDpInPx;
+
+ mTextPaint = new TextPaint();
+ mTextPaint.setAntiAlias(true);
+ mTextPaint.setSubpixelText(true);
+
+ mAlignment = Alignment.ALIGN_CENTER;
+
+ mPaint = new Paint();
+ mPaint.setAntiAlias(true);
+
+ mInnerPaddingX = 0;
+ setText(text);
+ setTextSize(textSize);
+ setStyle(CaptionStyleCompat.DEFAULT);
+ }
+
+ @Override
+ public void setBackgroundColor(int color) {
+ mBackgroundColor = color;
+ forceUpdate(false);
+ }
+
+ /**
+ * Sets the text to be displayed by the view.
+ *
+ * @param text The text to display.
+ */
+ public void setText(CharSequence text) {
+ this.mText = text;
+ forceUpdate(true);
+ }
+
+ /**
+ * Sets the text size in pixels.
+ *
+ * @param size The text size in pixels.
+ */
+ public void setTextSize(float size) {
+ if (mTextPaint.getTextSize() != size) {
+ mTextPaint.setTextSize(size);
+ mInnerPaddingX = (int) (size * INNER_PADDING_RATIO + 0.5f);
+ mWhiteSpaceWidth -= mInnerPaddingX * 2;
+ forceUpdate(true);
+ }
+ }
+
+ /**
+ * Sets the text alignment.
+ *
+ * @param textAlignment The text alignment.
+ */
+ public void setTextAlignment(Alignment textAlignment) {
+ mAlignment = textAlignment;
+ }
+
+ /**
+ * Configures the view according to the given style.
+ *
+ * @param style A style for the view.
+ */
+ public void setStyle(CaptionStyleCompat style) {
+ mForegroundColor = style.foregroundColor;
+ mBackgroundColor = style.backgroundColor;
+ mEdgeType = style.edgeType;
+ mEdgeColor = style.edgeColor;
+ setTypeface(style.typeface);
+ super.setBackgroundColor(style.windowColor);
+ forceUpdate(true);
+ }
+
+ public void setPrefixSpaces(ArrayList<Integer> prefixSpaces) {
+ mPrefixSpaces = prefixSpaces;
+ }
+
+ public void setWhiteSpaceWidth(float whiteSpaceWidth) {
+ mWhiteSpaceWidth = whiteSpaceWidth;
+ }
+
+ private void setTypeface(Typeface typeface) {
+ if (Objects.equals(mTextPaint.getTypeface(), (typeface))) {
+ mTextPaint.setTypeface(typeface);
+ forceUpdate(true);
+ }
+ }
+
+ private void forceUpdate(boolean needsLayout) {
+ if (needsLayout) {
+ mHasMeasurements = false;
+ requestLayout();
+ }
+ invalidate();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final int widthSpec = MeasureSpec.getSize(widthMeasureSpec);
+
+ if (computeMeasurements(widthSpec)) {
+ final StaticLayout layout = this.mLayout;
+ final int paddingX = getPaddingLeft() + getPaddingRight() + mInnerPaddingX * 2;
+ final int height = layout.getHeight() + getPaddingTop() + getPaddingBottom();
+ int width = 0;
+ int lineCount = layout.getLineCount();
+ for (int i = 0; i < lineCount; i++) {
+ width = Math.max((int) Math.ceil(layout.getLineWidth(i)), width);
+ }
+ width += paddingX;
+ setMeasuredDimension(width, height);
+ } else if (Util.SDK_INT >= 11) {
+ setTooSmallMeasureDimensionV11();
+ } else {
+ setMeasuredDimension(0, 0);
+ }
+ }
+
+ @TargetApi(11)
+ private void setTooSmallMeasureDimensionV11() {
+ setMeasuredDimension(MEASURED_STATE_TOO_SMALL, MEASURED_STATE_TOO_SMALL);
+ }
+
+ @Override
+ public void onLayout(boolean changed, int l, int t, int r, int b) {
+ final int width = r - l;
+ computeMeasurements(width);
+ }
+
+ private boolean computeMeasurements(int maxWidth) {
+ if (mHasMeasurements && maxWidth == mLastMeasuredWidth) {
+ return true;
+ }
+
+ // Account for padding.
+ final int paddingX = getPaddingLeft() + getPaddingRight() + mInnerPaddingX * 2;
+ maxWidth -= paddingX;
+ if (maxWidth <= 0) {
+ return false;
+ }
+
+ mHasMeasurements = true;
+ mLastMeasuredWidth = maxWidth;
+ mLayout =
+ new StaticLayout(
+ mText, mTextPaint, maxWidth, mAlignment, mSpacingMult, mSpacingAdd, true);
+ return true;
+ }
+
+ @Override
+ protected void onDraw(Canvas c) {
+ final StaticLayout layout = this.mLayout;
+ if (layout == null) {
+ return;
+ }
+
+ final int saveCount = c.save();
+ final int innerPaddingX = this.mInnerPaddingX;
+ c.translate(getPaddingLeft() + innerPaddingX, getPaddingTop());
+
+ final int lineCount = layout.getLineCount();
+ final Paint textPaint = this.mTextPaint;
+ final Paint paint = this.mPaint;
+ final RectF bounds = mLineBounds;
+
+ if (Color.alpha(mBackgroundColor) > 0) {
+ final float cornerRadius = this.mCornerRadius;
+ float previousBottom = layout.getLineTop(0);
+
+ paint.setColor(mBackgroundColor);
+ paint.setStyle(Style.FILL);
+
+ for (int i = 0; i < lineCount; i++) {
+ float spacesPadding = 0.0f;
+ if (i < mPrefixSpaces.size()) {
+ spacesPadding += mPrefixSpaces.get(i) * mWhiteSpaceWidth;
+ }
+ bounds.left = layout.getLineLeft(i) - innerPaddingX + spacesPadding;
+ bounds.right = layout.getLineRight(i) + innerPaddingX;
+ bounds.top = previousBottom;
+ bounds.bottom = layout.getLineBottom(i);
+ previousBottom = bounds.bottom;
+
+ c.drawRoundRect(bounds, cornerRadius, cornerRadius, paint);
+ }
+ }
+
+ if (mEdgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE) {
+ textPaint.setStrokeJoin(Join.ROUND);
+ textPaint.setStrokeWidth(mOutlineWidth);
+ textPaint.setColor(mEdgeColor);
+ textPaint.setStyle(Style.FILL_AND_STROKE);
+ layout.draw(c);
+ } else if (mEdgeType == CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW) {
+ textPaint.setShadowLayer(mShadowRadius, mShadowOffset, mShadowOffset, mEdgeColor);
+ } else if (mEdgeType == CaptionStyleCompat.EDGE_TYPE_RAISED
+ || mEdgeType == CaptionStyleCompat.EDGE_TYPE_DEPRESSED) {
+ boolean raised = mEdgeType == CaptionStyleCompat.EDGE_TYPE_RAISED;
+ int colorUp = raised ? Color.WHITE : mEdgeColor;
+ int colorDown = raised ? mEdgeColor : Color.WHITE;
+ float offset = mShadowRadius / 2f;
+ textPaint.setColor(mForegroundColor);
+ textPaint.setStyle(Style.FILL);
+ textPaint.setShadowLayer(mShadowRadius, -offset, -offset, colorUp);
+ layout.draw(c);
+ textPaint.setShadowLayer(mShadowRadius, offset, offset, colorDown);
+ }
+
+ textPaint.setColor(mForegroundColor);
+ textPaint.setStyle(Style.FILL);
+ layout.draw(c);
+ textPaint.setShadowLayer(0, 0, 0, 0);
+ c.restoreToCount(saveCount);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/layout/ScaledLayout.java b/tuner/src/com/android/tv/tuner/layout/ScaledLayout.java
new file mode 100644
index 00000000..dd92b641
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/layout/ScaledLayout.java
@@ -0,0 +1,290 @@
+/*
+ * 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.layout;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.hardware.display.DisplayManager;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.Display;
+import android.view.View;
+import android.view.ViewGroup;
+import com.android.tv.tuner.R;
+import java.util.Arrays;
+import java.util.Comparator;
+
+/** A layout that scales its children using the given percentage value. */
+public class ScaledLayout extends ViewGroup {
+ private static final String TAG = "ScaledLayout";
+ private static final boolean DEBUG = false;
+ private static final Comparator<Rect> mRectTopLeftSorter =
+ new Comparator<Rect>() {
+ @Override
+ public int compare(Rect lhs, Rect rhs) {
+ if (lhs.top != rhs.top) {
+ return lhs.top - rhs.top;
+ } else {
+ return lhs.left - rhs.left;
+ }
+ }
+ };
+
+ private Rect[] mRectArray;
+ private final int mMaxWidth;
+ private final int mMaxHeight;
+
+ public ScaledLayout(Context context) {
+ this(context, null);
+ }
+
+ public ScaledLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public ScaledLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Point size = new Point();
+ DisplayManager displayManager =
+ (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
+ Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY);
+ display.getRealSize(size);
+ mMaxWidth = size.x;
+ mMaxHeight = size.y;
+ }
+
+ /**
+ * ScaledLayoutParams stores the four scale factors. <br>
+ * Vertical coordinate system: ({@code scaleStartRow} * 100) % ~ ({@code scaleEndRow} * 100) %
+ * Horizontal coordinate system: ({@code scaleStartCol} * 100) % ~ ({@code scaleEndCol} * 100) %
+ * <br>
+ * In XML, for example,
+ *
+ * <pre>{@code
+ * <View
+ * app:layout_scaleStartRow="0.1"
+ * app:layout_scaleEndRow="0.5"
+ * app:layout_scaleStartCol="0.4"
+ * app:layout_scaleEndCol="1" />
+ * }</pre>
+ */
+ public static class ScaledLayoutParams extends ViewGroup.LayoutParams {
+ public static final float SCALE_UNSPECIFIED = -1;
+ public final float scaleStartRow;
+ public final float scaleEndRow;
+ public final float scaleStartCol;
+ public final float scaleEndCol;
+
+ public ScaledLayoutParams(
+ float scaleStartRow, float scaleEndRow, float scaleStartCol, float scaleEndCol) {
+ super(MATCH_PARENT, MATCH_PARENT);
+ this.scaleStartRow = scaleStartRow;
+ this.scaleEndRow = scaleEndRow;
+ this.scaleStartCol = scaleStartCol;
+ this.scaleEndCol = scaleEndCol;
+ }
+
+ public ScaledLayoutParams(Context context, AttributeSet attrs) {
+ super(MATCH_PARENT, MATCH_PARENT);
+ TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.utScaledLayout);
+ scaleStartRow =
+ array.getFloat(
+ R.styleable.utScaledLayout_layout_scaleStartRow, SCALE_UNSPECIFIED);
+ scaleEndRow =
+ array.getFloat(
+ R.styleable.utScaledLayout_layout_scaleEndRow, SCALE_UNSPECIFIED);
+ scaleStartCol =
+ array.getFloat(
+ R.styleable.utScaledLayout_layout_scaleStartCol, SCALE_UNSPECIFIED);
+ scaleEndCol =
+ array.getFloat(
+ R.styleable.utScaledLayout_layout_scaleEndCol, SCALE_UNSPECIFIED);
+ array.recycle();
+ }
+ }
+
+ @Override
+ public LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new ScaledLayoutParams(getContext(), attrs);
+ }
+
+ @Override
+ protected boolean checkLayoutParams(LayoutParams p) {
+ return (p instanceof ScaledLayoutParams);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
+ int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
+ int width = widthSpecSize - getPaddingLeft() - getPaddingRight();
+ int height = heightSpecSize - getPaddingTop() - getPaddingBottom();
+ if (DEBUG) {
+ Log.d(TAG, String.format("onMeasure width: %d, height: %d", width, height));
+ }
+ int count = getChildCount();
+ mRectArray = new Rect[count];
+ for (int i = 0; i < count; ++i) {
+ View child = getChildAt(i);
+ ViewGroup.LayoutParams params = child.getLayoutParams();
+ float scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol;
+ if (!(params instanceof ScaledLayoutParams)) {
+ throw new RuntimeException(
+ "A child of ScaledLayout cannot have the UNSPECIFIED scale factors");
+ }
+ scaleStartRow = ((ScaledLayoutParams) params).scaleStartRow;
+ scaleEndRow = ((ScaledLayoutParams) params).scaleEndRow;
+ scaleStartCol = ((ScaledLayoutParams) params).scaleStartCol;
+ scaleEndCol = ((ScaledLayoutParams) params).scaleEndCol;
+ if (scaleStartRow < 0 || scaleStartRow > 1) {
+ throw new RuntimeException(
+ "A child of ScaledLayout should have a range of "
+ + "scaleStartRow between 0 and 1");
+ }
+ if (scaleEndRow < scaleStartRow || scaleStartRow > 1) {
+ throw new RuntimeException(
+ "A child of ScaledLayout should have a range of "
+ + "scaleEndRow between scaleStartRow and 1");
+ }
+ if (scaleEndCol < 0 || scaleEndCol > 1) {
+ throw new RuntimeException(
+ "A child of ScaledLayout should have a range of "
+ + "scaleStartCol between 0 and 1");
+ }
+ if (scaleEndCol < scaleStartCol || scaleEndCol > 1) {
+ throw new RuntimeException(
+ "A child of ScaledLayout should have a range of "
+ + "scaleEndCol between scaleStartCol and 1");
+ }
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "onMeasure child scaleStartRow: %f scaleEndRow: %f "
+ + "scaleStartCol: %f scaleEndCol: %f",
+ scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol));
+ }
+ mRectArray[i] =
+ new Rect(
+ (int) (scaleStartCol * width),
+ (int) (scaleStartRow * height),
+ (int) (scaleEndCol * width),
+ (int) (scaleEndRow * height));
+ int scaleWidth = (int) (width * (scaleEndCol - scaleStartCol));
+ int childWidthSpec =
+ MeasureSpec.makeMeasureSpec(
+ scaleWidth > mMaxWidth ? mMaxWidth : scaleWidth, MeasureSpec.EXACTLY);
+ int childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ child.measure(childWidthSpec, childHeightSpec);
+
+ // If the height of the measured child view is bigger than the height of the calculated
+ // region by the given ScaleLayoutParams, the height of the region should be increased
+ // to fit the size of the child view.
+ if (child.getMeasuredHeight() > mRectArray[i].height()) {
+ int overflowedHeight = child.getMeasuredHeight() - mRectArray[i].height();
+ overflowedHeight = (overflowedHeight + 1) / 2;
+ mRectArray[i].bottom += overflowedHeight;
+ mRectArray[i].top -= overflowedHeight;
+ if (mRectArray[i].top < 0) {
+ mRectArray[i].bottom -= mRectArray[i].top;
+ mRectArray[i].top = 0;
+ }
+ if (mRectArray[i].bottom > height) {
+ mRectArray[i].top -= mRectArray[i].bottom - height;
+ mRectArray[i].bottom = height;
+ }
+ }
+ int scaleHeight = (int) (height * (scaleEndRow - scaleStartRow));
+ childHeightSpec =
+ MeasureSpec.makeMeasureSpec(
+ scaleHeight > mMaxHeight ? mMaxHeight : scaleHeight,
+ MeasureSpec.EXACTLY);
+ child.measure(childWidthSpec, childHeightSpec);
+ }
+
+ // Avoid overlapping rectangles.
+ // Step 1. Sort rectangles by position (top-left).
+ int visibleRectCount = 0;
+ int[] visibleRectGroup = new int[count];
+ Rect[] visibleRectArray = new Rect[count];
+ for (int i = 0; i < count; ++i) {
+ if (getChildAt(i).getVisibility() == View.VISIBLE) {
+ visibleRectGroup[visibleRectCount] = visibleRectCount;
+ visibleRectArray[visibleRectCount] = mRectArray[i];
+ ++visibleRectCount;
+ }
+ }
+ Arrays.sort(visibleRectArray, 0, visibleRectCount, mRectTopLeftSorter);
+
+ // Step 2. Move down if there are overlapping rectangles.
+ for (int i = 0; i < visibleRectCount - 1; ++i) {
+ for (int j = i + 1; j < visibleRectCount; ++j) {
+ if (Rect.intersects(visibleRectArray[i], visibleRectArray[j])) {
+ visibleRectGroup[j] = visibleRectGroup[i];
+ visibleRectArray[j].set(
+ visibleRectArray[j].left,
+ visibleRectArray[i].bottom,
+ visibleRectArray[j].right,
+ visibleRectArray[i].bottom + visibleRectArray[j].height());
+ }
+ }
+ }
+
+ // Step 3. Move up if there is any overflowed rectangle.
+ for (int i = visibleRectCount - 1; i >= 0; --i) {
+ if (visibleRectArray[i].bottom > height) {
+ int overflowedHeight = visibleRectArray[i].bottom - height;
+ for (int j = 0; j <= i; ++j) {
+ if (visibleRectGroup[i] == visibleRectGroup[j]) {
+ visibleRectArray[j].set(
+ visibleRectArray[j].left,
+ visibleRectArray[j].top - overflowedHeight,
+ visibleRectArray[j].right,
+ visibleRectArray[j].bottom - overflowedHeight);
+ }
+ }
+ }
+ }
+ setMeasuredDimension(widthSpecSize, heightSpecSize);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ int paddingLeft = getPaddingLeft();
+ int paddingTop = getPaddingTop();
+ int count = getChildCount();
+ for (int i = 0; i < count; ++i) {
+ View child = getChildAt(i);
+ if (child.getVisibility() != GONE) {
+ int childLeft = paddingLeft + mRectArray[i].left;
+ int childTop = paddingTop + mRectArray[i].top;
+ int childBottom = paddingLeft + mRectArray[i].bottom;
+ int childRight = paddingTop + mRectArray[i].right;
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "layoutChild bottom: %d left: %d right: %d top: %d",
+ childBottom, childLeft, childRight, childTop));
+ }
+ child.layout(childLeft, childTop, childRight, childBottom);
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/livetuner/LiveTvTunerTvInputService.java b/tuner/src/com/android/tv/tuner/livetuner/LiveTvTunerTvInputService.java
new file mode 100644
index 00000000..f741fdb0
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/livetuner/LiveTvTunerTvInputService.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2018 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.livetuner;
+
+import com.android.tv.tuner.tvinput.BaseTunerTvInputService;
+
+/** Live TV embedded tuner. */
+public class LiveTvTunerTvInputService extends BaseTunerTvInputService {}
diff --git a/tuner/src/com/android/tv/tuner/setup/BaseTunerSetupActivity.java b/tuner/src/com/android/tv/tuner/setup/BaseTunerSetupActivity.java
new file mode 100644
index 00000000..1be4e1c2
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/setup/BaseTunerSetupActivity.java
@@ -0,0 +1,516 @@
+/*
+ * 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.setup;
+
+import android.app.Fragment;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.MainThread;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.support.annotation.WorkerThread;
+import android.support.v4.app.NotificationCompat;
+import android.text.TextUtils;
+import android.util.Log;
+import android.widget.Toast;
+import com.android.tv.common.BaseApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.experiments.Experiments;
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.common.ui.setup.SetupActivity;
+import com.android.tv.common.ui.setup.SetupFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.common.util.AutoCloseableUtils;
+import com.android.tv.common.util.PostalCodeUtils;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.TunerPreferences;
+import java.util.concurrent.Executor;
+
+/** The base setup activity class for tuner. */
+public class BaseTunerSetupActivity extends SetupActivity {
+ private static final String TAG = "BaseTunerSetupActivity";
+ private static final boolean DEBUG = false;
+
+ /** Key for passing tuner type to sub-fragments. */
+ public static final String KEY_TUNER_TYPE = "TunerSetupActivity.tunerType";
+
+ // For the notification.
+ protected static final String TUNER_SET_UP_NOTIFICATION_CHANNEL_ID = "tuner_setup_channel";
+ protected static final String NOTIFY_TAG = "TunerSetup";
+ protected static final int NOTIFY_ID = 1000;
+ protected static final String TAG_DRAWABLE = "drawable";
+ protected static final String TAG_ICON = "ic_launcher_s";
+ protected static final int PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION = 1;
+
+ protected static final int[] CHANNEL_MAP_SCAN_FILE = {
+ R.raw.ut_us_atsc_center_frequencies_8vsb,
+ R.raw.ut_us_cable_standard_center_frequencies_qam256,
+ R.raw.ut_us_all,
+ R.raw.ut_kr_atsc_center_frequencies_8vsb,
+ R.raw.ut_kr_cable_standard_center_frequencies_qam256,
+ R.raw.ut_kr_all,
+ R.raw.ut_kr_dev_cj_cable_center_frequencies_qam256,
+ R.raw.ut_euro_dvbt_all,
+ R.raw.ut_euro_dvbt_all,
+ R.raw.ut_euro_dvbt_all
+ };
+
+ protected ScanFragment mLastScanFragment;
+ protected Integer mTunerType;
+ protected boolean mNeedToShowPostalCodeFragment;
+ protected String mPreviousPostalCode;
+ protected boolean mActivityStopped;
+ protected boolean mPendingShowInitialFragment;
+
+ private TunerHalFactory mTunerHalFactory;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ if (DEBUG) {
+ Log.d(TAG, "onCreate");
+ }
+ mActivityStopped = false;
+ executeGetTunerTypeAndCountAsyncTask();
+ mTunerHalFactory =
+ new TunerHalFactory(getApplicationContext(), AsyncTask.THREAD_POOL_EXECUTOR);
+ super.onCreate(savedInstanceState);
+ // TODO: check {@link shouldShowRequestPermissionRationale}.
+ if (checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION)
+ != PackageManager.PERMISSION_GRANTED) {
+ // No need to check the request result.
+ requestPermissions(
+ new String[] {android.Manifest.permission.ACCESS_COARSE_LOCATION},
+ PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION);
+ }
+ try {
+ // Updating postal code takes time, therefore we called it here for "warm-up".
+ mPreviousPostalCode = PostalCodeUtils.getLastPostalCode(this);
+ PostalCodeUtils.setLastPostalCode(this, null);
+ PostalCodeUtils.updatePostalCode(this);
+ } catch (Exception e) {
+ // Do nothing. If the last known postal code is null, we'll show guided fragment to
+ // prompt users to input postal code before ConnectionTypeFragment is shown.
+ Log.i(TAG, "Can't get postal code:" + e);
+ }
+ }
+
+ protected void executeGetTunerTypeAndCountAsyncTask() {}
+
+ @Override
+ protected void onStop() {
+ mActivityStopped = true;
+ super.onStop();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mActivityStopped = false;
+ if (mPendingShowInitialFragment) {
+ showInitialFragment();
+ mPendingShowInitialFragment = false;
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ if (requestCode == PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION) {
+ if (grantResults.length > 0
+ && grantResults[0] == PackageManager.PERMISSION_GRANTED
+ && Experiments.CLOUD_EPG.get()) {
+ try {
+ // Updating postal code takes time, therefore we should update postal code
+ // right after the permission is granted, so that the subsequent operations,
+ // especially EPG fetcher, could get the newly updated postal code.
+ PostalCodeUtils.updatePostalCode(this);
+ } catch (Exception e) {
+ // Do nothing
+ }
+ }
+ }
+ }
+
+ @Override
+ protected Fragment onCreateInitialFragment() {
+ if (mTunerType != null) {
+ SetupFragment fragment = new WelcomeFragment();
+ Bundle args = new Bundle();
+ args.putInt(KEY_TUNER_TYPE, mTunerType);
+ fragment.setArguments(args);
+ fragment.setShortDistance(
+ SetupFragment.FRAGMENT_EXIT_TRANSITION
+ | SetupFragment.FRAGMENT_REENTER_TRANSITION);
+ return fragment;
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ protected boolean executeAction(String category, int actionId, Bundle params) {
+ switch (category) {
+ case WelcomeFragment.ACTION_CATEGORY:
+ switch (actionId) {
+ case SetupMultiPaneFragment.ACTION_DONE:
+ // If the scan was performed, then the result should be OK.
+ setResult(mLastScanFragment == null ? RESULT_CANCELED : RESULT_OK);
+ finish();
+ break;
+ default:
+ String postalCode = PostalCodeUtils.getLastPostalCode(this);
+ if (mNeedToShowPostalCodeFragment
+ || (CommonFeatures.ENABLE_CLOUD_EPG_REGION.isEnabled(
+ getApplicationContext())
+ && TextUtils.isEmpty(postalCode))) {
+ // We cannot get postal code automatically. Postal code input fragment
+ // should always be shown even if users have input some valid postal
+ // code in this activity before.
+ mNeedToShowPostalCodeFragment = true;
+ showPostalCodeFragment();
+ } else {
+ showConnectionTypeFragment();
+ }
+ break;
+ }
+ return true;
+ case PostalCodeFragment.ACTION_CATEGORY:
+ switch (actionId) {
+ case SetupMultiPaneFragment.ACTION_DONE:
+ // fall through
+ case SetupMultiPaneFragment.ACTION_SKIP:
+ showConnectionTypeFragment();
+ break;
+ default: // fall out
+ }
+ return true;
+ case ConnectionTypeFragment.ACTION_CATEGORY:
+ if (mTunerHalFactory.getOrCreate() == null) {
+ finish();
+ Toast.makeText(
+ getApplicationContext(),
+ R.string.ut_channel_scan_tuner_unavailable,
+ Toast.LENGTH_LONG)
+ .show();
+ return true;
+ }
+ mLastScanFragment = new ScanFragment();
+ Bundle args1 = new Bundle();
+ args1.putInt(
+ ScanFragment.EXTRA_FOR_CHANNEL_SCAN_FILE, CHANNEL_MAP_SCAN_FILE[actionId]);
+ args1.putInt(KEY_TUNER_TYPE, mTunerType);
+ mLastScanFragment.setArguments(args1);
+ showFragment(mLastScanFragment, true);
+ return true;
+ case ScanFragment.ACTION_CATEGORY:
+ switch (actionId) {
+ case ScanFragment.ACTION_CANCEL:
+ getFragmentManager().popBackStack();
+ return true;
+ case ScanFragment.ACTION_FINISH:
+ mTunerHalFactory.clear();
+ showScanResultFragment();
+ return true;
+ default: // fall out
+ }
+ break;
+ case ScanResultFragment.ACTION_CATEGORY:
+ switch (actionId) {
+ case SetupMultiPaneFragment.ACTION_DONE:
+ setResult(RESULT_OK);
+ finish();
+ break;
+ default:
+ // scan again
+ SetupFragment fragment = new ConnectionTypeFragment();
+ fragment.setShortDistance(
+ SetupFragment.FRAGMENT_ENTER_TRANSITION
+ | SetupFragment.FRAGMENT_RETURN_TRANSITION);
+ showFragment(fragment, true);
+ break;
+ }
+ return true;
+ default: // fall out
+ }
+ return false;
+ }
+
+ @Override
+ public void onDestroy() {
+ if (mPreviousPostalCode != null && PostalCodeUtils.getLastPostalCode(this) == null) {
+ PostalCodeUtils.setLastPostalCode(this, mPreviousPostalCode);
+ }
+ super.onDestroy();
+ }
+
+ /** Gets the currently used tuner HAL. */
+ TunerHal getTunerHal() {
+ return mTunerHalFactory.getOrCreate();
+ }
+
+ /** Generates tuner HAL. */
+ void generateTunerHal() {
+ mTunerHalFactory.generate();
+ }
+
+ /** Clears the currently used tuner HAL. */
+ protected void clearTunerHal() {
+ mTunerHalFactory.clear();
+ }
+
+ protected void showPostalCodeFragment() {
+ SetupFragment fragment = new PostalCodeFragment();
+ fragment.setShortDistance(
+ SetupFragment.FRAGMENT_ENTER_TRANSITION | SetupFragment.FRAGMENT_RETURN_TRANSITION);
+ showFragment(fragment, true);
+ }
+
+ protected void showConnectionTypeFragment() {
+ SetupFragment fragment = new ConnectionTypeFragment();
+ fragment.setShortDistance(
+ SetupFragment.FRAGMENT_ENTER_TRANSITION | SetupFragment.FRAGMENT_RETURN_TRANSITION);
+ showFragment(fragment, true);
+ }
+
+ protected void showScanResultFragment() {
+ SetupFragment scanResultFragment = new ScanResultFragment();
+ Bundle args2 = new Bundle();
+ args2.putInt(KEY_TUNER_TYPE, mTunerType);
+ scanResultFragment.setShortDistance(
+ SetupFragment.FRAGMENT_EXIT_TRANSITION | SetupFragment.FRAGMENT_REENTER_TRANSITION);
+ showFragment(scanResultFragment, true);
+ }
+
+ /**
+ * Cancels the previously shown notification.
+ *
+ * @param context a {@link Context} instance
+ */
+ public static void cancelNotification(Context context) {
+ NotificationManager notificationManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(NOTIFY_TAG, NOTIFY_ID);
+ }
+
+ /**
+ * A callback to be invoked when the TvInputService is enabled or disabled.
+ *
+ * @param context a {@link Context} instance
+ * @param enabled {@code true} for the {@link TunerTvInputService} to be enabled; otherwise
+ * {@code false}
+ */
+ public static void onTvInputEnabled(Context context, boolean enabled, Integer tunerType) {
+ // Send a notification for tuner setup if there's no channels and the tuner TV input
+ // setup has been not done.
+ boolean channelScanDoneOnPreference = TunerPreferences.isScanDone(context);
+ int channelCountOnPreference = TunerPreferences.getScannedChannelCount(context);
+ if (enabled && !channelScanDoneOnPreference && channelCountOnPreference == 0) {
+ TunerPreferences.setShouldShowSetupActivity(context, true);
+ sendNotification(context, tunerType);
+ } else {
+ TunerPreferences.setShouldShowSetupActivity(context, false);
+ cancelNotification(context);
+ }
+ }
+
+ private static void sendNotification(Context context, Integer tunerType) {
+ SoftPreconditions.checkState(
+ tunerType != null, TAG, "tunerType is null when send notification");
+ if (tunerType == null) {
+ return;
+ }
+ Resources resources = context.getResources();
+ String contentTitle = resources.getString(R.string.ut_setup_notification_content_title);
+ int contentTextId = 0;
+ switch (tunerType) {
+ case TunerHal.TUNER_TYPE_BUILT_IN:
+ contentTextId = R.string.bt_setup_notification_content_text;
+ break;
+ case TunerHal.TUNER_TYPE_USB:
+ contentTextId = R.string.ut_setup_notification_content_text;
+ break;
+ case TunerHal.TUNER_TYPE_NETWORK:
+ contentTextId = R.string.nt_setup_notification_content_text;
+ break;
+ default: // fall out
+ }
+ String contentText = resources.getString(contentTextId);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ sendNotificationInternal(context, contentTitle, contentText);
+ } else {
+ Bitmap largeIcon =
+ BitmapFactory.decodeResource(resources, R.drawable.recommendation_antenna);
+ sendRecommendationCard(context, contentTitle, contentText, largeIcon);
+ }
+ }
+
+ private static void sendNotificationInternal(
+ Context context, String contentTitle, String contentText) {
+ NotificationManager notificationManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.createNotificationChannel(
+ new NotificationChannel(
+ TUNER_SET_UP_NOTIFICATION_CHANNEL_ID,
+ context.getResources()
+ .getString(R.string.ut_setup_notification_channel_name),
+ NotificationManager.IMPORTANCE_HIGH));
+ Notification notification =
+ new Notification.Builder(context, TUNER_SET_UP_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle(contentTitle)
+ .setContentText(contentText)
+ .setSmallIcon(
+ context.getResources()
+ .getIdentifier(
+ TAG_ICON, TAG_DRAWABLE, context.getPackageName()))
+ .setContentIntent(createPendingIntentForSetupActivity(context))
+ .setVisibility(Notification.VISIBILITY_PUBLIC)
+ .extend(new Notification.TvExtender())
+ .build();
+ notificationManager.notify(NOTIFY_TAG, NOTIFY_ID, notification);
+ }
+
+ /**
+ * Sends the recommendation card to start the tuner TV input setup activity.
+ *
+ * @param context a {@link Context} instance
+ */
+ private static void sendRecommendationCard(
+ Context context, String contentTitle, String contentText, Bitmap largeIcon) {
+ // Build and send the notification.
+ Notification notification =
+ new NotificationCompat.BigPictureStyle(
+ new NotificationCompat.Builder(context)
+ .setAutoCancel(false)
+ .setContentTitle(contentTitle)
+ .setContentText(contentText)
+ .setContentInfo(contentText)
+ .setCategory(Notification.CATEGORY_RECOMMENDATION)
+ .setLargeIcon(largeIcon)
+ .setSmallIcon(
+ context.getResources()
+ .getIdentifier(
+ TAG_ICON,
+ TAG_DRAWABLE,
+ context.getPackageName()))
+ .setContentIntent(
+ createPendingIntentForSetupActivity(context)))
+ .build();
+ NotificationManager notificationManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.notify(NOTIFY_TAG, NOTIFY_ID, notification);
+ }
+
+ /**
+ * Returns a {@link PendingIntent} to launch the tuner TV input service.
+ *
+ * @param context a {@link Context} instance
+ */
+ private static PendingIntent createPendingIntentForSetupActivity(Context context) {
+ return PendingIntent.getActivity(
+ context,
+ 0,
+ BaseApplication.getSingletons(context).getTunerSetupIntent(context),
+ PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ /** A static factory for {@link TunerHal} instances * */
+ @VisibleForTesting
+ protected static class TunerHalFactory {
+ private Context mContext;
+ @VisibleForTesting TunerHal mTunerHal;
+ private TunerHalFactory.GenerateTunerHalTask mGenerateTunerHalTask;
+ private final Executor mExecutor;
+
+ TunerHalFactory(Context context) {
+ this(context, AsyncTask.SERIAL_EXECUTOR);
+ }
+
+ TunerHalFactory(Context context, Executor executor) {
+ mContext = context;
+ mExecutor = executor;
+ }
+
+ /**
+ * Returns tuner HAL currently used. If it's {@code null} and tuner HAL is not generated
+ * before, tries to generate it synchronously.
+ */
+ @WorkerThread
+ TunerHal getOrCreate() {
+ if (mGenerateTunerHalTask != null
+ && mGenerateTunerHalTask.getStatus() != AsyncTask.Status.FINISHED) {
+ try {
+ return mGenerateTunerHalTask.get();
+ } catch (Exception e) {
+ Log.e(TAG, "Cannot get Tuner HAL: " + e);
+ }
+ } else if (mGenerateTunerHalTask == null && mTunerHal == null) {
+ mTunerHal = createInstance();
+ }
+ return mTunerHal;
+ }
+
+ /** Generates tuner hal for scanning with asynchronous tasks. */
+ @MainThread
+ void generate() {
+ if (mGenerateTunerHalTask == null && mTunerHal == null) {
+ mGenerateTunerHalTask = new TunerHalFactory.GenerateTunerHalTask();
+ mGenerateTunerHalTask.executeOnExecutor(mExecutor);
+ }
+ }
+
+ /** Clears the currently used tuner hal. */
+ @MainThread
+ void clear() {
+ if (mGenerateTunerHalTask != null) {
+ mGenerateTunerHalTask.cancel(true);
+ mGenerateTunerHalTask = null;
+ }
+ if (mTunerHal != null) {
+ AutoCloseableUtils.closeQuietly(mTunerHal);
+ mTunerHal = null;
+ }
+ }
+
+ @WorkerThread
+ protected TunerHal createInstance() {
+ return TunerHal.createInstance(mContext);
+ }
+
+ class GenerateTunerHalTask extends AsyncTask<Void, Void, TunerHal> {
+ @Override
+ protected TunerHal doInBackground(Void... args) {
+ return createInstance();
+ }
+
+ @Override
+ protected void onPostExecute(TunerHal tunerHal) {
+ mTunerHal = tunerHal;
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java b/tuner/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java
new file mode 100644
index 00000000..ebe4e41e
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java
@@ -0,0 +1,101 @@
+/*
+ * 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.setup;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import com.android.tv.common.BuildConfig;
+import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.tuner.R;
+import java.util.List;
+import java.util.TimeZone;
+
+/** A fragment for connection type selection. */
+public class ConnectionTypeFragment extends SetupMultiPaneFragment {
+ public static final String ACTION_CATEGORY =
+ "com.android.tv.tuner.setup.ConnectionTypeFragment";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ ((BaseTunerSetupActivity) getActivity()).generateTunerHal();
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onResume() {
+ ((BaseTunerSetupActivity) getActivity()).generateTunerHal();
+ super.onResume();
+ }
+
+ @Override
+ public void onDestroy() {
+ ((BaseTunerSetupActivity) getActivity()).clearTunerHal();
+ super.onDestroy();
+ }
+
+ @Override
+ protected SetupGuidedStepFragment onCreateContentFragment() {
+ return new ContentFragment();
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ @Override
+ protected boolean needsDoneButton() {
+ return false;
+ }
+
+ /** The content fragment of {@link ConnectionTypeFragment}. */
+ public static class ContentFragment extends SetupGuidedStepFragment {
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ return new Guidance(
+ getString(R.string.ut_connection_title),
+ getString(R.string.ut_connection_description),
+ getString(R.string.ut_setup_breadcrumb),
+ null);
+ }
+
+ @Override
+ public void onCreateActions(
+ @NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ String[] choices = getResources().getStringArray(R.array.ut_connection_choices);
+ int length = choices.length - 1;
+ int startOffset = 0;
+ for (int i = 0; i < length; ++i) {
+ actions.add(
+ new GuidedAction.Builder(getActivity())
+ .id(startOffset + i)
+ .title(choices[i])
+ .build());
+ }
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/setup/LineupFragment.java b/tuner/src/com/android/tv/tuner/setup/LineupFragment.java
new file mode 100644
index 00000000..41f755df
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/setup/LineupFragment.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright (C) 2017 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.setup;
+
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.util.Log;
+import android.view.View;
+import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.tuner.R;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Lineup Fragment shows available lineups and lets users select one of them. */
+public class LineupFragment extends SetupMultiPaneFragment {
+ public static final String TAG = "LineupFragment";
+ public static final boolean DEBUG = false;
+
+ public static final String ACTION_CATEGORY = "com.android.tv.tuner.setup.LineupFragment";
+ public static final String KEY_LINEUP_NAMES = "lineup_names";
+ public static final String KEY_MATCH_NUMBERS = "match_numbers";
+ public static final String KEY_DEFAULT_LINEUP = "default_lineup";
+ public static final String KEY_LINEUP_NOT_FOUND = "lineup_not_found";
+ public static final int ACTION_ID_RETRY = SetupMultiPaneFragment.MAX_SUBCLASSES_ID - 1;
+
+ private ContentFragment contentFragment;
+ private Bundle args;
+ private ArrayList<String> lineups;
+ private boolean lineupNotFound;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ if (savedInstanceState == null) {
+ lineups = getArguments().getStringArrayList(KEY_LINEUP_NAMES);
+ }
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected SetupGuidedStepFragment onCreateContentFragment() {
+ contentFragment = new LineupFragment.ContentFragment();
+ Bundle args = new Bundle();
+ Bundle arguments = this.args != null ? this.args : getArguments();
+ args.putStringArrayList(KEY_LINEUP_NAMES, lineups);
+ args.putIntegerArrayList(
+ KEY_MATCH_NUMBERS, arguments.getIntegerArrayList(KEY_MATCH_NUMBERS));
+ args.putInt(KEY_DEFAULT_LINEUP, arguments.getInt(KEY_DEFAULT_LINEUP));
+ args.putBoolean(KEY_LINEUP_NOT_FOUND, lineupNotFound);
+ contentFragment.setArguments(args);
+ return contentFragment;
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ public void onLineupFound(Bundle args) {
+ if (DEBUG) {
+ Log.d(TAG, "onLineupFound");
+ }
+ this.args = args;
+ lineups = args.getStringArrayList(KEY_LINEUP_NAMES);
+ lineupNotFound = false;
+ if (contentFragment != null) {
+ updateContentFragment();
+ }
+ }
+
+ public void onLineupNotFound() {
+ if (DEBUG) {
+ Log.d(TAG, "onLineupNotFound");
+ }
+ lineupNotFound = true;
+ if (contentFragment != null) {
+ updateContentFragment();
+ }
+ }
+
+ public void onRetry() {
+ if (DEBUG) {
+ Log.d(TAG, "onRetry");
+ }
+ if (contentFragment != null) {
+ lineupNotFound = false;
+ lineups = null;
+ updateContentFragment();
+ } else {
+ // onRetry() can be called only when retry button is clicked.
+ // This should never happen.
+ throw new RuntimeException(
+ "ContentFragment hasn't been created when onRetry() is called");
+ }
+ }
+
+ @Override
+ protected boolean needsDoneButton() {
+ return false;
+ }
+
+ private void updateContentFragment() {
+ contentFragment = (ContentFragment) onCreateContentFragment();
+ getChildFragmentManager()
+ .beginTransaction()
+ .replace(
+ com.android.tv.common.R.id.guided_step_fragment_container,
+ contentFragment,
+ SetupMultiPaneFragment.CONTENT_FRAGMENT_TAG)
+ .commit();
+ }
+
+ /** The content fragment of {@link LineupFragment}. */
+ public static class ContentFragment extends SetupGuidedStepFragment {
+
+ private ArrayList<String> lineups;
+ private ArrayList<Integer> matchNumbers;
+ private int defaultLineup;
+ private boolean lineupNotFound;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ if (savedInstanceState == null) {
+ lineups = getArguments().getStringArrayList(KEY_LINEUP_NAMES);
+ matchNumbers = getArguments().getIntegerArrayList(KEY_MATCH_NUMBERS);
+ defaultLineup = getArguments().getInt(KEY_DEFAULT_LINEUP);
+ this.lineupNotFound = getArguments().getBoolean(KEY_LINEUP_NOT_FOUND);
+ }
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ int position = findActionPositionById(defaultLineup);
+ if (position >= 0 && position < getActions().size()) {
+ setSelectedActionPosition(position);
+ }
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ if ((lineups != null && lineups.isEmpty()) || this.lineupNotFound) {
+ return new Guidance(
+ getString(R.string.ut_lineup_title_lineups_not_found),
+ getString(R.string.ut_lineup_description_lineups_not_found),
+ getString(R.string.ut_setup_breadcrumb),
+ null);
+ } else if (lineups == null) {
+ return new Guidance(
+ getString(R.string.ut_lineup_title_fetching_lineups),
+ getString(R.string.ut_lineup_description_fetching_lineups),
+ getString(R.string.ut_setup_breadcrumb),
+ null);
+ }
+ return new Guidance(
+ getString(R.string.ut_lineup_title_lineups_found),
+ getString(R.string.ut_lineup_description_lineups_found),
+ getString(R.string.ut_setup_breadcrumb),
+ null);
+ }
+
+ @Override
+ public void onCreateActions(
+ @NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ actions.addAll(buildActions());
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ private List<GuidedAction> buildActions() {
+ List<GuidedAction> actions = new ArrayList<>();
+
+ if ((lineups != null && lineups.isEmpty()) || this.lineupNotFound) {
+ actions.add(
+ new GuidedAction.Builder(getActivity())
+ .id(ACTION_ID_RETRY)
+ .title(com.android.tv.common.R.string.action_text_retry)
+ .build());
+ actions.add(
+ new GuidedAction.Builder(getActivity())
+ .id(ACTION_SKIP)
+ .title(com.android.tv.common.R.string.action_text_skip)
+ .build());
+ } else if (lineups == null) {
+ actions.add(
+ new GuidedAction.Builder(getActivity())
+ .id(ACTION_SKIP)
+ .title(com.android.tv.common.R.string.action_text_skip)
+ .build());
+ } else {
+ Resources res = getResources();
+ for (int i = 0; i < lineups.size(); ++i) {
+ int matchNumber = matchNumbers.get(i);
+ String description =
+ matchNumber == 0
+ ? res.getString(R.string.ut_lineup_no_channels_matched)
+ : res.getQuantityString(
+ R.plurals.ut_lineup_channels_matched,
+ matchNumber,
+ matchNumber);
+ actions.add(
+ new GuidedAction.Builder(getActivity())
+ .id(i)
+ .title(lineups.get(i))
+ .description(description)
+ .build());
+ }
+ }
+ return actions;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/setup/LiveTvTunerSetupActivity.java b/tuner/src/com/android/tv/tuner/setup/LiveTvTunerSetupActivity.java
new file mode 100644
index 00000000..722de7c6
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/setup/LiveTvTunerSetupActivity.java
@@ -0,0 +1,75 @@
+/*
+ * 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.setup;
+
+import android.app.FragmentManager;
+import android.os.AsyncTask;
+import android.view.KeyEvent;
+import com.android.tv.tuner.TunerHal;
+
+/** An activity that serves tuner setup process. */
+public class LiveTvTunerSetupActivity extends BaseTunerSetupActivity {
+ private static final String TAG = "LiveTvTunerSetupActivity";
+
+ @Override
+ protected void executeGetTunerTypeAndCountAsyncTask() {
+ new AsyncTask<Void, Void, Integer>() {
+ @Override
+ protected Integer doInBackground(Void... arg0) {
+ return TunerHal.getTunerTypeAndCount(LiveTvTunerSetupActivity.this).first;
+ }
+
+ @Override
+ protected void onPostExecute(Integer result) {
+ if (!LiveTvTunerSetupActivity.this.isDestroyed()) {
+ mTunerType = result;
+ if (result == null) {
+ finish();
+ } else if (!mActivityStopped) {
+ showInitialFragment();
+ } else {
+ mPendingShowInitialFragment = true;
+ }
+ }
+ }
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ FragmentManager manager = getFragmentManager();
+ int count = manager.getBackStackEntryCount();
+ if (count > 0) {
+ String lastTag = manager.getBackStackEntryAt(count - 1).getName();
+ if (ScanResultFragment.class.getCanonicalName().equals(lastTag) && count >= 2) {
+ String secondLastTag = manager.getBackStackEntryAt(count - 2).getName();
+ if (ScanFragment.class.getCanonicalName().equals(secondLastTag)) {
+ // Pops fragment including ScanFragment.
+ manager.popBackStack(
+ secondLastTag, FragmentManager.POP_BACK_STACK_INCLUSIVE);
+ return true;
+ }
+ } else if (ScanFragment.class.getCanonicalName().equals(lastTag)) {
+ mLastScanFragment.finishScan(true);
+ return true;
+ }
+ }
+ }
+ return super.onKeyUp(keyCode, event);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/setup/PostalCodeFragment.java b/tuner/src/com/android/tv/tuner/setup/PostalCodeFragment.java
new file mode 100644
index 00000000..f4b9f65e
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/setup/PostalCodeFragment.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2017 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.setup;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.support.v17.leanback.widget.GuidedActionsStylist;
+import android.text.InputFilter;
+import android.text.InputFilter.AllCaps;
+import android.view.View;
+import android.widget.TextView;
+import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.common.util.LocationUtils;
+import com.android.tv.common.util.PostalCodeUtils;
+import com.android.tv.tuner.R;
+import java.util.List;
+
+/** A fragment for initial screen. */
+public class PostalCodeFragment extends SetupMultiPaneFragment {
+ public static final String ACTION_CATEGORY = "com.android.tv.tuner.setup.PostalCodeFragment";
+ public static final String KEY_POSTAL_CODE = "postal_code";
+ private static final int VIEW_TYPE_EDITABLE = 1;
+
+ @Override
+ protected SetupGuidedStepFragment onCreateContentFragment() {
+ ContentFragment fragment = new ContentFragment();
+ Bundle arguments = new Bundle();
+ arguments.putBoolean(SetupGuidedStepFragment.KEY_THREE_PANE, true);
+ fragment.setArguments(arguments);
+ return fragment;
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ @Override
+ protected boolean needsDoneButton() {
+ return true;
+ }
+
+ @Override
+ protected boolean needsSkipButton() {
+ return true;
+ }
+
+ @Override
+ protected void setOnClickAction(View view, final String category, final int actionId) {
+ if (actionId == ACTION_DONE) {
+ view.setOnClickListener(
+ new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ CharSequence postalCode =
+ ((ContentFragment) getContentFragment())
+ .mEditedActionTitleView.getText();
+ String region = LocationUtils.getCurrentCountry(getContext());
+ if (postalCode != null && PostalCodeUtils.matches(postalCode, region)) {
+ String postalCodeString = postalCode.toString();
+ PostalCodeUtils.setLastPostalCode(getContext(), postalCodeString);
+ Bundle params = new Bundle();
+ params.putString(KEY_POSTAL_CODE, postalCodeString);
+ onActionClick(category, actionId, params);
+ } else {
+ ContentFragment contentFragment =
+ (ContentFragment) getContentFragment();
+ contentFragment.mEditAction.setDescription(
+ getString(R.string.postal_code_invalid_warning));
+ contentFragment.notifyActionChanged(0);
+ contentFragment.mEditedActionView.performClick();
+ }
+ }
+ });
+ } else if (actionId == ACTION_SKIP) {
+ super.setOnClickAction(view, category, ACTION_SKIP);
+ }
+ }
+
+ /** The content fragment of {@link PostalCodeFragment}. */
+ public static class ContentFragment extends SetupGuidedStepFragment {
+ private GuidedAction mEditAction;
+ private View mEditedActionView;
+ private TextView mEditedActionTitleView;
+ private View mDoneActionView;
+ private boolean mProceed;
+
+ @Override
+ public void onGuidedActionFocused(GuidedAction action) {
+ if (action.equals(mEditAction)) {
+ if (mProceed) {
+ // "NEXT" in IME was just clicked, moves focus to Done button.
+ if (mDoneActionView == null) {
+ mDoneActionView = getDoneButton();
+ }
+ mDoneActionView.requestFocus();
+ mProceed = false;
+ } else {
+ // Directly opens IME to input postal/zip code.
+ if (mEditedActionView == null) {
+ int maxLength = PostalCodeUtils.getRegionMaxLength(getContext());
+ mEditedActionView = getView().findViewById(R.id.guidedactions_editable);
+ mEditedActionTitleView =
+ mEditedActionView.findViewById(R.id.guidedactions_item_title);
+ mEditedActionTitleView.setFilters(
+ new InputFilter[] {
+ new InputFilter.LengthFilter(maxLength), new AllCaps()
+ });
+ }
+ mEditedActionView.performClick();
+ }
+ }
+ }
+
+ @Override
+ public long onGuidedActionEditedAndProceed(GuidedAction action) {
+ mProceed = true;
+ return 0;
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getString(R.string.postal_code_guidance_title);
+ String description = getString(R.string.postal_code_guidance_description);
+ String breadcrumb = getString(R.string.ut_setup_breadcrumb);
+ return new Guidance(title, description, breadcrumb, null);
+ }
+
+ @Override
+ public void onCreateActions(
+ @NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ String description = getString(R.string.postal_code_action_description);
+ mEditAction =
+ new GuidedAction.Builder(getActivity())
+ .id(0)
+ .editable(true)
+ .description(description)
+ .build();
+ actions.add(mEditAction);
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ @Override
+ public GuidedActionsStylist onCreateActionsStylist() {
+ return new GuidedActionsStylist() {
+ @Override
+ public int getItemViewType(GuidedAction action) {
+ if (action.isEditable()) {
+ return VIEW_TYPE_EDITABLE;
+ }
+ return super.getItemViewType(action);
+ }
+
+ @Override
+ public int onProvideItemLayoutId(int viewType) {
+ if (viewType == VIEW_TYPE_EDITABLE) {
+ return R.layout.guided_action_editable;
+ }
+ return super.onProvideItemLayoutId(viewType);
+ }
+ };
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/setup/ScanFragment.java b/tuner/src/com/android/tv/tuner/setup/ScanFragment.java
new file mode 100644
index 00000000..3ac86e19
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/setup/ScanFragment.java
@@ -0,0 +1,553 @@
+/*
+ * 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.setup;
+
+import android.animation.LayoutTransition;
+import android.app.Activity;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.Button;
+import android.widget.ListView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.ui.setup.SetupFragment;
+import com.android.tv.tuner.ChannelScanFileParser;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.PsipData;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.data.nano.Channel;
+
+
+import com.android.tv.tuner.source.FileTsStreamer;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsStreamer;
+import com.android.tv.tuner.source.TunerTsStreamer;
+import com.android.tv.tuner.tvinput.ChannelDataManager;
+import com.android.tv.tuner.tvinput.EventDetector;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/** A fragment for scanning channels. */
+public class ScanFragment extends SetupFragment {
+ private static final String TAG = "ScanFragment";
+ private static final boolean DEBUG = false;
+
+ // In the fake mode, the connection to antenna or cable is not necessary.
+ // Instead dummy channels are added.
+ private static final boolean FAKE_MODE = false;
+
+ private static final String VCTLESS_CHANNEL_NAME_FORMAT = "RF%d-%d";
+
+ public static final String ACTION_CATEGORY = "com.android.tv.tuner.setup.ScanFragment";
+ public static final int ACTION_CANCEL = 1;
+ public static final int ACTION_FINISH = 2;
+
+ public static final String EXTRA_FOR_CHANNEL_SCAN_FILE = "scan_file_choice";
+ public static final String KEY_CHANNEL_NUMBERS = "channel_numbers";
+ private static final long CHANNEL_SCAN_SHOW_DELAY_MS = 10000;
+ private static final long CHANNEL_SCAN_PERIOD_MS = 4000;
+ private static final long SHOW_PROGRESS_DIALOG_DELAY_MS = 300;
+
+ // Build channels out of the locally stored TS streams.
+ private static final boolean SCAN_LOCAL_STREAMS = true;
+
+ private ChannelDataManager mChannelDataManager;
+ private ChannelScanTask mChannelScanTask;
+ private ProgressBar mProgressBar;
+ private TextView mScanningMessage;
+ private View mChannelHolder;
+ private ChannelAdapter mAdapter;
+ private volatile boolean mChannelListVisible;
+ private Button mCancelButton;
+
+ private ArrayList<String> mChannelNumbers;
+
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ if (DEBUG) Log.d(TAG, "onCreateView");
+ View view = super.onCreateView(inflater, container, savedInstanceState);
+ mChannelNumbers = new ArrayList<>();
+ mChannelDataManager = new ChannelDataManager(getActivity());
+ mChannelDataManager.checkDataVersion(getActivity());
+ mAdapter = new ChannelAdapter();
+ mProgressBar = (ProgressBar) view.findViewById(R.id.tune_progress);
+ mScanningMessage = (TextView) view.findViewById(R.id.tune_description);
+ ListView channelList = (ListView) view.findViewById(R.id.channel_list);
+ channelList.setAdapter(mAdapter);
+ channelList.setOnItemClickListener(null);
+ ViewGroup progressHolder = (ViewGroup) view.findViewById(R.id.progress_holder);
+ LayoutTransition transition = new LayoutTransition();
+ transition.enableTransitionType(LayoutTransition.CHANGING);
+ progressHolder.setLayoutTransition(transition);
+ mChannelHolder = view.findViewById(R.id.channel_holder);
+ mCancelButton = (Button) view.findViewById(R.id.tune_cancel);
+ mCancelButton.setOnClickListener(
+ new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ finishScan(false);
+ }
+ });
+ Bundle args = getArguments();
+ int tunerType = (args == null ? 0 : args.getInt(BaseTunerSetupActivity.KEY_TUNER_TYPE, 0));
+ // TODO: Handle the case when the fragment is restored.
+ startScan(args == null ? 0 : args.getInt(EXTRA_FOR_CHANNEL_SCAN_FILE, 0));
+ TextView scanTitleView = (TextView) view.findViewById(R.id.tune_title);
+ switch (tunerType) {
+ case TunerHal.TUNER_TYPE_USB:
+ scanTitleView.setText(R.string.ut_channel_scan);
+ break;
+ case TunerHal.TUNER_TYPE_NETWORK:
+ scanTitleView.setText(R.string.nt_channel_scan);
+ break;
+ default:
+ scanTitleView.setText(R.string.bt_channel_scan);
+ }
+ return view;
+ }
+
+ @Override
+ protected int getLayoutResourceId() {
+ return R.layout.ut_channel_scan;
+ }
+
+ @Override
+ protected int[] getParentIdsForDelay() {
+ return new int[] {R.id.progress_holder};
+ }
+
+ private void startScan(int channelMapId) {
+ mChannelScanTask = new ChannelScanTask(channelMapId);
+ mChannelScanTask.execute();
+ }
+
+ @Override
+ public void onPause() {
+ Log.d(TAG, "onPause");
+ if (mChannelScanTask != null) {
+ // Ensure scan task will stop.
+ Log.w(TAG, "The activity went to the background. Stopping channel scan.");
+ mChannelScanTask.stopScan();
+ }
+ super.onPause();
+ }
+
+ /**
+ * Finishes the current scan thread. This fragment will be popped after the scan thread ends.
+ *
+ * @param cancel a flag which indicates the scan is canceled or not.
+ */
+ public void finishScan(boolean cancel) {
+ if (mChannelScanTask != null) {
+ mChannelScanTask.cancelScan(cancel);
+
+ // Notifies a user of waiting to finish the scanning process.
+ new Handler()
+ .postDelayed(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mChannelScanTask != null) {
+ mChannelScanTask.showFinishingProgressDialog();
+ }
+ }
+ },
+ SHOW_PROGRESS_DIALOG_DELAY_MS);
+
+ // Hides the cancel button.
+ mCancelButton.setEnabled(false);
+ }
+ }
+
+ private static class ChannelAdapter extends BaseAdapter {
+ private final ArrayList<TunerChannel> mChannels;
+
+ public ChannelAdapter() {
+ mChannels = new ArrayList<>();
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean isEnabled(int pos) {
+ return false;
+ }
+
+ @Override
+ public int getCount() {
+ return mChannels.size();
+ }
+
+ @Override
+ public Object getItem(int pos) {
+ return pos;
+ }
+
+ @Override
+ public long getItemId(int pos) {
+ return pos;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final Context context = parent.getContext();
+
+ if (convertView == null) {
+ LayoutInflater inflater =
+ (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.ut_channel_list, parent, false);
+ }
+
+ TextView channelNum = (TextView) convertView.findViewById(R.id.channel_num);
+ channelNum.setText(mChannels.get(position).getDisplayNumber());
+
+ TextView channelName = (TextView) convertView.findViewById(R.id.channel_name);
+ channelName.setText(mChannels.get(position).getName());
+ return convertView;
+ }
+
+ public void add(TunerChannel channel) {
+ mChannels.add(channel);
+ notifyDataSetChanged();
+ }
+ }
+
+ private class ChannelScanTask extends AsyncTask<Void, Integer, Void>
+ implements EventDetector.EventListener, ChannelDataManager.ChannelScanListener {
+ private static final int MAX_PROGRESS = 100;
+
+ private final Activity mActivity;
+ private final int mChannelMapId;
+ private final TsStreamer mScanTsStreamer;
+ private final TsStreamer mFileTsStreamer;
+ private final ConditionVariable mConditionStopped;
+
+ private final List<ChannelScanFileParser.ScanChannel> mScanChannelList = new ArrayList<>();
+ private boolean mIsCanceled;
+ private boolean mIsFinished;
+ private ProgressDialog mFinishingProgressDialog;
+ private CountDownLatch mLatch;
+
+ public ChannelScanTask(int channelMapId) {
+ mActivity = getActivity();
+ mChannelMapId = channelMapId;
+ if (FAKE_MODE) {
+ mScanTsStreamer = new FakeTsStreamer(this);
+ } else {
+ TunerHal hal = ((BaseTunerSetupActivity) mActivity).getTunerHal();
+ if (hal == null) {
+ throw new RuntimeException("Failed to open a DVB device");
+ }
+ mScanTsStreamer = new TunerTsStreamer(hal, this);
+ }
+ mFileTsStreamer = SCAN_LOCAL_STREAMS ? new FileTsStreamer(this, mActivity) : null;
+ mConditionStopped = new ConditionVariable();
+ mChannelDataManager.setChannelScanListener(this, new Handler());
+ }
+
+ private void maybeSetChannelListVisible() {
+ mActivity.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ int channelsFound = mAdapter.getCount();
+ if (!mChannelListVisible && channelsFound > 0) {
+ String format =
+ getResources()
+ .getQuantityString(
+ R.plurals.ut_channel_scan_message,
+ channelsFound,
+ channelsFound);
+ mScanningMessage.setText(String.format(format, channelsFound));
+ mChannelHolder.setVisibility(View.VISIBLE);
+ mChannelListVisible = true;
+ }
+ }
+ });
+ }
+
+ private void addChannel(final TunerChannel channel) {
+ mActivity.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ mAdapter.add(channel);
+ if (mChannelListVisible) {
+ int channelsFound = mAdapter.getCount();
+ String format =
+ getResources()
+ .getQuantityString(
+ R.plurals.ut_channel_scan_message,
+ channelsFound,
+ channelsFound);
+ mScanningMessage.setText(String.format(format, channelsFound));
+ }
+ }
+ });
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ mScanChannelList.clear();
+ if (SCAN_LOCAL_STREAMS) {
+ FileTsStreamer.addLocalStreamFiles(mScanChannelList);
+ }
+ mScanChannelList.addAll(
+ ChannelScanFileParser.parseScanFile(
+ getResources().openRawResource(mChannelMapId)));
+ scanChannels();
+ return null;
+ }
+
+ @Override
+ protected void onCancelled() {
+ SoftPreconditions.checkState(false, TAG, "call cancelScan instead of cancel");
+ }
+
+ @Override
+ protected void onProgressUpdate(Integer... values) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ mProgressBar.setProgress(values[0], true);
+ } else {
+ mProgressBar.setProgress(values[0]);
+ }
+ }
+
+ private void stopScan() {
+ if (mLatch != null) {
+ mLatch.countDown();
+ }
+ mConditionStopped.open();
+ }
+
+ private void cancelScan(boolean cancel) {
+ mIsCanceled = cancel;
+ stopScan();
+ }
+
+ private void scanChannels() {
+ if (DEBUG) Log.i(TAG, "Channel scan starting");
+ mChannelDataManager.notifyScanStarted();
+
+ long startMs = System.currentTimeMillis();
+ int i = 1;
+ for (ChannelScanFileParser.ScanChannel scanChannel : mScanChannelList) {
+ int frequency = scanChannel.frequency;
+ String modulation = scanChannel.modulation;
+ Log.i(TAG, "Tuning to " + frequency + " " + modulation);
+
+ TsStreamer streamer = getStreamer(scanChannel.type);
+ SoftPreconditions.checkNotNull(streamer);
+ if (streamer != null && streamer.startStream(scanChannel)) {
+ mLatch = new CountDownLatch(1);
+ try {
+ mLatch.await(CHANNEL_SCAN_PERIOD_MS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Log.e(
+ TAG,
+ "The current thread is interrupted during scanChannels(). "
+ + "The TS stream is stopped earlier than expected.",
+ e);
+ }
+ streamer.stopStream();
+ addChannelsWithoutVct(scanChannel);
+ if (System.currentTimeMillis() > startMs + CHANNEL_SCAN_SHOW_DELAY_MS
+ && !mChannelListVisible) {
+ maybeSetChannelListVisible();
+ }
+ }
+ if (mConditionStopped.block(-1)) {
+ break;
+ }
+ publishProgress(MAX_PROGRESS * i++ / mScanChannelList.size());
+ }
+ mChannelDataManager.notifyScanCompleted();
+ if (!mConditionStopped.block(-1)) {
+ publishProgress(MAX_PROGRESS);
+ }
+ if (DEBUG) Log.i(TAG, "Channel scan ended");
+ }
+
+ private void addChannelsWithoutVct(ChannelScanFileParser.ScanChannel scanChannel) {
+ if (scanChannel.radioFrequencyNumber == null
+ || !(mScanTsStreamer instanceof TunerTsStreamer)) {
+ return;
+ }
+ for (TunerChannel tunerChannel :
+ ((TunerTsStreamer) mScanTsStreamer).getMalFormedChannels()) {
+ if ((tunerChannel.getVideoPid() != TunerChannel.INVALID_PID)
+ && (tunerChannel.getAudioPid() != TunerChannel.INVALID_PID)) {
+ tunerChannel.setFrequency(scanChannel.frequency);
+ tunerChannel.setModulation(scanChannel.modulation);
+ tunerChannel.setShortName(
+ String.format(
+ Locale.US,
+ VCTLESS_CHANNEL_NAME_FORMAT,
+ scanChannel.radioFrequencyNumber,
+ tunerChannel.getProgramNumber()));
+ tunerChannel.setVirtualMajor(scanChannel.radioFrequencyNumber);
+ tunerChannel.setVirtualMinor(tunerChannel.getProgramNumber());
+ onChannelDetected(tunerChannel, true);
+ }
+ }
+ }
+
+ private TsStreamer getStreamer(int type) {
+ switch (type) {
+ case Channel.TunerType.TYPE_TUNER:
+ return mScanTsStreamer;
+ case Channel.TunerType.TYPE_FILE:
+ return mFileTsStreamer;
+ default:
+ return null;
+ }
+ }
+
+ @Override
+ public void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items) {
+ mChannelDataManager.notifyEventDetected(channel, items);
+ }
+
+ @Override
+ public void onChannelScanDone() {
+ if (mLatch != null) {
+ mLatch.countDown();
+ }
+ }
+
+ @Override
+ public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) {
+ if (channelArrivedAtFirstTime) {
+ Log.i(TAG, "Found channel " + channel);
+ }
+ if (channelArrivedAtFirstTime && channel.hasAudio()) {
+ // Playbacks with video-only stream have not been tested yet.
+ // No video-only channel has been found.
+ addChannel(channel);
+ mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime);
+ mChannelNumbers.add(channel.getDisplayNumber());
+ }
+ }
+
+ public void showFinishingProgressDialog() {
+ // Show a progress dialog to wait for the scanning process if it's not done yet.
+ if (!mIsFinished && mFinishingProgressDialog == null) {
+ mFinishingProgressDialog =
+ ProgressDialog.show(
+ mActivity, "", getString(R.string.ut_setup_cancel), true, false);
+ }
+ }
+
+ @Override
+ public void onChannelHandlingDone() {
+ mChannelDataManager.setCurrentVersion(mActivity);
+ mChannelDataManager.releaseSafely();
+ mIsFinished = true;
+ TunerPreferences.setScannedChannelCount(
+ mActivity.getApplicationContext(),
+ mChannelDataManager.getScannedChannelCount());
+ // Cancel a previously shown notification.
+ BaseTunerSetupActivity.cancelNotification(mActivity.getApplicationContext());
+ // Mark scan as done
+ TunerPreferences.setScanDone(mActivity.getApplicationContext());
+ // finishing will be done manually.
+ if (mFinishingProgressDialog != null) {
+ mFinishingProgressDialog.dismiss();
+ }
+ // If the fragment is not resumed, the next fragment (scan result page) can't be
+ // displayed. In that case, just close the activity.
+ if (isResumed()) {
+ if (mIsCanceled) {
+ onActionClick(ACTION_CATEGORY, ACTION_CANCEL);
+ } else {
+ Bundle params = new Bundle();
+ params.putStringArrayList(KEY_CHANNEL_NUMBERS, mChannelNumbers);
+ onActionClick(ACTION_CATEGORY, ACTION_FINISH, params);
+ }
+ } else if (getActivity() != null) {
+ getActivity().finish();
+ }
+ mChannelScanTask = null;
+ }
+ }
+
+ private static class FakeTsStreamer implements TsStreamer {
+ private final EventDetector.EventListener mEventListener;
+ private int mProgramNumber = 0;
+
+ FakeTsStreamer(EventDetector.EventListener eventListener) {
+ mEventListener = eventListener;
+ }
+
+ @Override
+ public boolean startStream(ChannelScanFileParser.ScanChannel channel) {
+ if (++mProgramNumber % 2 == 1) {
+ return true;
+ }
+ final String displayNumber = Integer.toString(mProgramNumber);
+ final String name = "Channel-" + mProgramNumber;
+ mEventListener.onChannelDetected(
+ new TunerChannel(mProgramNumber, new ArrayList<>()) {
+ @Override
+ public String getDisplayNumber() {
+ return displayNumber;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+ },
+ true);
+ return true;
+ }
+
+ @Override
+ public boolean startStream(TunerChannel channel) {
+ return false;
+ }
+
+ @Override
+ public void stopStream() {}
+
+ @Override
+ public TsDataSource createDataSource() {
+ return null;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/setup/ScanResultFragment.java b/tuner/src/com/android/tv/tuner/setup/ScanResultFragment.java
new file mode 100644
index 00000000..480bf081
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/setup/ScanResultFragment.java
@@ -0,0 +1,134 @@
+/*
+ * 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.setup;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.TunerPreferences;
+import java.util.List;
+
+/** A fragment for initial screen. */
+public class ScanResultFragment extends SetupMultiPaneFragment {
+ public static final String ACTION_CATEGORY = "com.android.tv.tuner.setup.ScanResultFragment";
+
+ @Override
+ protected SetupGuidedStepFragment onCreateContentFragment() {
+ Bundle args = new Bundle();
+ ContentFragment fragment = new ContentFragment();
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ @Override
+ protected boolean needsDoneButton() {
+ return false;
+ }
+
+ /** The content fragment of {@link ScanResultFragment}. */
+ public static class ContentFragment extends SetupGuidedStepFragment {
+ private int mChannelCountOnPreference;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mChannelCountOnPreference = TunerPreferences.getScannedChannelCount(context);
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title;
+ String description;
+ String breadcrumb;
+ if (mChannelCountOnPreference > 0) {
+ Resources res = getResources();
+ title =
+ res.getQuantityString(
+ R.plurals.ut_result_found_title,
+ mChannelCountOnPreference,
+ mChannelCountOnPreference);
+ description = res.getString(R.string.ut_result_found_description);
+
+ breadcrumb = null;
+ } else {
+ Bundle args = getArguments();
+ int tunerType =
+ (args == null ? 0 : args.getInt(BaseTunerSetupActivity.KEY_TUNER_TYPE, 0));
+ title = getString(R.string.ut_result_not_found_title);
+ switch (tunerType) {
+ case TunerHal.TUNER_TYPE_USB:
+ description = getString(R.string.ut_result_not_found_description);
+ break;
+ case TunerHal.TUNER_TYPE_NETWORK:
+ description = getString(R.string.nt_result_not_found_description);
+ break;
+ default:
+ description = getString(R.string.bt_result_not_found_description);
+ }
+ breadcrumb = getString(R.string.ut_setup_breadcrumb);
+ }
+ return new Guidance(title, description, breadcrumb, null);
+ }
+
+ @Override
+ public void onCreateActions(
+ @NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ String[] choices;
+ int doneActionIndex;
+ if (mChannelCountOnPreference > 0) {
+ choices = getResources().getStringArray(R.array.ut_result_found_choices);
+ doneActionIndex = 0;
+ } else {
+ choices = getResources().getStringArray(R.array.ut_result_not_found_choices);
+ doneActionIndex = 1;
+ }
+ for (int i = 0; i < choices.length; ++i) {
+ if (i == doneActionIndex) {
+ actions.add(
+ new GuidedAction.Builder(getActivity())
+ .id(ACTION_DONE)
+ .title(choices[i])
+ .build());
+ } else {
+ actions.add(
+ new GuidedAction.Builder(getActivity())
+ .id(i)
+ .title(choices[i])
+ .build());
+ }
+ }
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/setup/WelcomeFragment.java b/tuner/src/com/android/tv/tuner/setup/WelcomeFragment.java
new file mode 100644
index 00000000..788ba918
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/setup/WelcomeFragment.java
@@ -0,0 +1,128 @@
+/*
+ * 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.setup;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.TunerPreferences;
+import java.util.List;
+
+/** A fragment for initial screen. */
+public class WelcomeFragment extends SetupMultiPaneFragment {
+ public static final String ACTION_CATEGORY = "com.android.tv.tuner.setup.WelcomeFragment";
+
+ @Override
+ protected SetupGuidedStepFragment onCreateContentFragment() {
+ ContentFragment fragment = new ContentFragment();
+ fragment.setArguments(getArguments());
+ return fragment;
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ @Override
+ protected boolean needsDoneButton() {
+ return false;
+ }
+
+ /** The content fragment of {@link WelcomeFragment}. */
+ public static class ContentFragment extends SetupGuidedStepFragment {
+ private int mChannelCountOnPreference;
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ mChannelCountOnPreference =
+ TunerPreferences.getScannedChannelCount(getActivity().getApplicationContext());
+ super.onCreate(savedInstanceState);
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title;
+ String description;
+ int tunerType =
+ getArguments()
+ .getInt(
+ BaseTunerSetupActivity.KEY_TUNER_TYPE,
+ TunerHal.TUNER_TYPE_BUILT_IN);
+ if (mChannelCountOnPreference == 0) {
+ switch (tunerType) {
+ case TunerHal.TUNER_TYPE_USB:
+ title = getString(R.string.ut_setup_new_title);
+ description = getString(R.string.ut_setup_new_description);
+ break;
+ case TunerHal.TUNER_TYPE_NETWORK:
+ title = getString(R.string.nt_setup_new_title);
+ description = getString(R.string.nt_setup_new_description);
+ break;
+ default:
+ title = getString(R.string.bt_setup_new_title);
+ description = getString(R.string.bt_setup_new_description);
+ }
+ } else {
+ title = getString(R.string.bt_setup_again_title);
+ switch (tunerType) {
+ case TunerHal.TUNER_TYPE_USB:
+ description = getString(R.string.ut_setup_again_description);
+ break;
+ case TunerHal.TUNER_TYPE_NETWORK:
+ description = getString(R.string.nt_setup_again_description);
+ break;
+ default:
+ description = getString(R.string.bt_setup_again_description);
+ }
+ }
+ return new Guidance(title, description, null, null);
+ }
+
+ @Override
+ public void onCreateActions(
+ @NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ String[] choices =
+ getResources()
+ .getStringArray(
+ mChannelCountOnPreference == 0
+ ? R.array.ut_setup_new_choices
+ : R.array.ut_setup_again_choices);
+ for (int i = 0; i < choices.length - 1; ++i) {
+ actions.add(
+ new GuidedAction.Builder(getActivity()).id(i).title(choices[i]).build());
+ }
+ actions.add(
+ new GuidedAction.Builder(getActivity())
+ .id(ACTION_DONE)
+ .title(choices[choices.length - 1])
+ .build());
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/source/FileTsStreamer.java b/tuner/src/com/android/tv/tuner/source/FileTsStreamer.java
new file mode 100644
index 00000000..38a59b3d
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/source/FileTsStreamer.java
@@ -0,0 +1,487 @@
+/*
+ * 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.source;
+
+import android.content.Context;
+import android.os.Environment;
+import android.util.Log;
+import android.util.SparseBooleanArray;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.ChannelScanFileParser.ScanChannel;
+import com.android.tv.tuner.TunerFeatures;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.ts.TsParser;
+import com.android.tv.tuner.tvinput.EventDetector;
+import com.android.tv.tuner.tvinput.FileSourceEventDetector;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.upstream.DataSpec;
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Provides MPEG-2 TS stream sources for both channel scanning and channel playing from a local file
+ * generated by capturing TV signal.
+ */
+public class FileTsStreamer implements TsStreamer {
+ private static final String TAG = "FileTsStreamer";
+
+ private static final int TS_PACKET_SIZE = 188;
+ private static final int TS_SYNC_BYTE = 0x47;
+ private static final int MIN_READ_UNIT = TS_PACKET_SIZE * 10;
+ private static final int READ_BUFFER_SIZE = MIN_READ_UNIT * 10; // ~20KB
+ private static final int CIRCULAR_BUFFER_SIZE = MIN_READ_UNIT * 4000; // ~ 8MB
+ private static final int PADDING_SIZE = MIN_READ_UNIT * 1000; // ~2MB
+ private static final int READ_TIMEOUT_MS = 10000; // 10 secs.
+ private static final int BUFFER_UNDERRUN_SLEEP_MS = 10;
+ private static final String FILE_DIR =
+ new File(Environment.getExternalStorageDirectory(), "Streams").getAbsolutePath();
+
+ // Virtual frequency base used for file-based source
+ public static final int FREQ_BASE = 100;
+
+ private final Object mCircularBufferMonitor = new Object();
+ private final byte[] mCircularBuffer = new byte[CIRCULAR_BUFFER_SIZE];
+ private final FileSourceEventDetector mEventDetector;
+ private final Context mContext;
+
+ private long mBytesFetched;
+ private long mLastReadPosition;
+ private boolean mStreaming;
+
+ private Thread mStreamingThread;
+ private StreamProvider mSource;
+
+ public static class FileDataSource extends TsDataSource {
+ private final FileTsStreamer mTsStreamer;
+ private final AtomicLong mLastReadPosition = new AtomicLong(0);
+ private long mStartBufferedPosition;
+
+ private FileDataSource(FileTsStreamer tsStreamer) {
+ mTsStreamer = tsStreamer;
+ mStartBufferedPosition = tsStreamer.getBufferedPosition();
+ }
+
+ @Override
+ public long getBufferedPosition() {
+ return mTsStreamer.getBufferedPosition() - mStartBufferedPosition;
+ }
+
+ @Override
+ public long getLastReadPosition() {
+ return mLastReadPosition.get();
+ }
+
+ @Override
+ public void shiftStartPosition(long offset) {
+ SoftPreconditions.checkState(mLastReadPosition.get() == 0);
+ SoftPreconditions.checkArgument(0 <= offset && offset <= getBufferedPosition());
+ mStartBufferedPosition += offset;
+ }
+
+ @Override
+ public long open(DataSpec dataSpec) throws IOException {
+ mLastReadPosition.set(0);
+ return C.LENGTH_UNBOUNDED;
+ }
+
+ @Override
+ public void close() {}
+
+ @Override
+ public int read(byte[] buffer, int offset, int readLength) throws IOException {
+ int ret =
+ mTsStreamer.readAt(
+ mStartBufferedPosition + mLastReadPosition.get(),
+ buffer,
+ offset,
+ readLength);
+ if (ret > 0) {
+ mLastReadPosition.addAndGet(ret);
+ }
+ return ret;
+ }
+ }
+
+ /**
+ * Creates {@link TsStreamer} for scanning & playing MPEG-2 TS file.
+ *
+ * @param eventListener the listener for channel & program information
+ */
+ public FileTsStreamer(EventDetector.EventListener eventListener, Context context) {
+ mEventDetector =
+ new FileSourceEventDetector(
+ eventListener, TunerFeatures.ENABLE_FILE_DVB.isEnabled(context));
+ mContext = context;
+ }
+
+ @Override
+ public boolean startStream(ScanChannel channel) {
+ String filepath = new File(FILE_DIR, channel.filename).getAbsolutePath();
+ mSource = new StreamProvider(filepath);
+ if (!mSource.isReady()) {
+ return false;
+ }
+ mEventDetector.start(mSource, FileSourceEventDetector.ALL_PROGRAM_NUMBERS);
+ mSource.addPidFilter(TsParser.PAT_PID);
+ mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID);
+ if (TunerFeatures.ENABLE_FILE_DVB.isEnabled(mContext)) {
+ mSource.addPidFilter(TsParser.DVB_EIT_PID);
+ mSource.addPidFilter(TsParser.DVB_SDT_PID);
+ }
+ synchronized (mCircularBufferMonitor) {
+ if (mStreaming) {
+ return true;
+ }
+ mStreaming = true;
+ }
+
+ mStreamingThread = new StreamingThread();
+ mStreamingThread.start();
+ Log.i(TAG, "Streaming started");
+ return true;
+ }
+
+ @Override
+ public boolean startStream(TunerChannel channel) {
+ Log.i(TAG, "tuneToChannel with: " + channel.getFilepath());
+ mSource = new StreamProvider(channel.getFilepath());
+ if (!mSource.isReady()) {
+ return false;
+ }
+ mEventDetector.start(mSource, channel.getProgramNumber());
+ mSource.addPidFilter(channel.getVideoPid());
+ for (Integer i : channel.getAudioPids()) {
+ mSource.addPidFilter(i);
+ }
+ mSource.addPidFilter(channel.getPcrPid());
+ mSource.addPidFilter(TsParser.PAT_PID);
+ mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID);
+ if (TunerFeatures.ENABLE_FILE_DVB.isEnabled(mContext)) {
+ mSource.addPidFilter(TsParser.DVB_EIT_PID);
+ mSource.addPidFilter(TsParser.DVB_SDT_PID);
+ }
+ synchronized (mCircularBufferMonitor) {
+ if (mStreaming) {
+ return true;
+ }
+ mStreaming = true;
+ }
+
+ mStreamingThread = new StreamingThread();
+ mStreamingThread.start();
+ Log.i(TAG, "Streaming started");
+ return true;
+ }
+
+ /**
+ * Blocks the current thread until the streaming thread stops. In rare cases when the tuner
+ * device is overloaded this can take a while, but usually it returns pretty quickly.
+ */
+ @Override
+ public void stopStream() {
+ synchronized (mCircularBufferMonitor) {
+ mStreaming = false;
+ mCircularBufferMonitor.notify();
+ }
+
+ try {
+ if (mStreamingThread != null) {
+ mStreamingThread.join();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ public TsDataSource createDataSource() {
+ return new FileDataSource(this);
+ }
+
+ /**
+ * Returns the current buffered position from the file.
+ *
+ * @return the current buffered position
+ */
+ public long getBufferedPosition() {
+ synchronized (mCircularBufferMonitor) {
+ return mBytesFetched;
+ }
+ }
+
+ /** Provides MPEG-2 transport stream from a local file. Stream can be filtered by PID. */
+ public static class StreamProvider {
+ private final String mFilepath;
+ private final SparseBooleanArray mPids = new SparseBooleanArray();
+ private final byte[] mPreBuffer = new byte[READ_BUFFER_SIZE];
+
+ private BufferedInputStream mInputStream;
+
+ private StreamProvider(String filepath) {
+ mFilepath = filepath;
+ open(filepath);
+ }
+
+ private void open(String filepath) {
+ try {
+ mInputStream = new BufferedInputStream(new FileInputStream(filepath));
+ } catch (IOException e) {
+ Log.e(TAG, "Error opening input stream", e);
+ mInputStream = null;
+ }
+ }
+
+ private boolean isReady() {
+ return mInputStream != null;
+ }
+
+ /** Returns the file path of the MPEG-2 TS file. */
+ public String getFilepath() {
+ return mFilepath;
+ }
+
+ /** Adds a pid for filtering from the MPEG-2 TS file. */
+ public void addPidFilter(int pid) {
+ mPids.put(pid, true);
+ }
+
+ /** Returns whether the current pid filter is empty or not. */
+ public boolean isFilterEmpty() {
+ return mPids.size() == 0;
+ }
+
+ /** Clears the current pid filter. */
+ public void clearPidFilter() {
+ mPids.clear();
+ }
+
+ /**
+ * Returns whether a pid is in the pid filter or not.
+ *
+ * @param pid the pid to check
+ */
+ public boolean isInFilter(int pid) {
+ return mPids.get(pid);
+ }
+
+ /**
+ * Reads from the MPEG-2 TS file to buffer.
+ *
+ * @param inputBuffer to read
+ * @return the number of read bytes
+ */
+ private int read(byte[] inputBuffer) {
+ int readSize = readInternal();
+ if (readSize <= 0) {
+ // Reached the end of stream. Restart from the beginning.
+ close();
+ open(mFilepath);
+ if (mInputStream == null) {
+ return -1;
+ }
+ readSize = readInternal();
+ }
+
+ if (mPreBuffer[0] != TS_SYNC_BYTE) {
+ Log.e(TAG, "Error reading input stream - no TS sync found");
+ return -1;
+ }
+ int filteredSize = 0;
+ for (int i = 0, destPos = 0; i < readSize; i += TS_PACKET_SIZE) {
+ if (mPreBuffer[i] == TS_SYNC_BYTE) {
+ int pid = ((mPreBuffer[i + 1] & 0x1f) << 8) + (mPreBuffer[i + 2] & 0xff);
+ if (mPids.get(pid)) {
+ System.arraycopy(mPreBuffer, i, inputBuffer, destPos, TS_PACKET_SIZE);
+ destPos += TS_PACKET_SIZE;
+ filteredSize += TS_PACKET_SIZE;
+ }
+ }
+ }
+ return filteredSize;
+ }
+
+ private int readInternal() {
+ int readSize;
+ try {
+ readSize = mInputStream.read(mPreBuffer, 0, mPreBuffer.length);
+ } catch (IOException e) {
+ Log.e(TAG, "Error reading input stream", e);
+ return -1;
+ }
+ return readSize;
+ }
+
+ private void close() {
+ try {
+ mInputStream.close();
+ } catch (IOException e) {
+ Log.e(TAG, "Error closing input stream:", e);
+ }
+ mInputStream = null;
+ }
+ }
+
+ /**
+ * Reads data from internal buffer.
+ *
+ * @param pos the position to read from
+ * @param buffer to read
+ * @param offset start position of the read buffer
+ * @param amount number of bytes to read
+ * @return number of read bytes when successful, {@code -1} otherwise
+ * @throws IOException
+ */
+ public int readAt(long pos, byte[] buffer, int offset, int amount) throws IOException {
+ synchronized (mCircularBufferMonitor) {
+ long initialBytesFetched = mBytesFetched;
+ while (mBytesFetched < pos + amount && mStreaming) {
+ try {
+ mCircularBufferMonitor.wait(READ_TIMEOUT_MS);
+ } catch (InterruptedException e) {
+ // Wait again.
+ Thread.currentThread().interrupt();
+ }
+ if (initialBytesFetched == mBytesFetched) {
+ Log.w(TAG, "No data update for " + READ_TIMEOUT_MS + "ms. returning -1.");
+
+ // Returning -1 will make demux report EOS so that the input service can retry
+ // the playback.
+ return -1;
+ }
+ }
+ if (!mStreaming) {
+ Log.w(TAG, "Stream is already stopped.");
+ return -1;
+ }
+ if (mBytesFetched - CIRCULAR_BUFFER_SIZE > pos) {
+ Log.e(TAG, "Demux is requesting the data which is already overwritten.");
+ return -1;
+ }
+ int posInBuffer = (int) (pos % CIRCULAR_BUFFER_SIZE);
+ int bytesToCopyInFirstPass = amount;
+ if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) {
+ bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer;
+ }
+ System.arraycopy(mCircularBuffer, posInBuffer, buffer, offset, bytesToCopyInFirstPass);
+ if (bytesToCopyInFirstPass < amount) {
+ System.arraycopy(
+ mCircularBuffer,
+ 0,
+ buffer,
+ offset + bytesToCopyInFirstPass,
+ amount - bytesToCopyInFirstPass);
+ }
+ mLastReadPosition = pos + amount;
+ mCircularBufferMonitor.notify();
+ return amount;
+ }
+ }
+
+ /**
+ * Adds {@link ScanChannel} instance for local files.
+ *
+ * @param output a list of channels where the results will be placed in
+ */
+ public static void addLocalStreamFiles(List<ScanChannel> output) {
+ File dir = new File(FILE_DIR);
+ if (!dir.exists()) return;
+
+ File[] tsFiles = dir.listFiles();
+ if (tsFiles == null) return;
+ int freq = FileTsStreamer.FREQ_BASE;
+ for (File file : tsFiles) {
+ if (!file.isFile()) continue;
+ output.add(ScanChannel.forFile(freq, file.getName()));
+ freq += 100;
+ }
+ }
+
+ /**
+ * A thread managing a circular buffer that holds stream data to be consumed by player. Keeps
+ * reading data in from a {@link StreamProvider} to hold enough amount for buffering. Started
+ * and stopped by {@link #startStream()} and {@link #stopStream()}, respectively.
+ */
+ private class StreamingThread extends Thread {
+ @Override
+ public void run() {
+ byte[] dataBuffer = new byte[READ_BUFFER_SIZE];
+
+ synchronized (mCircularBufferMonitor) {
+ mBytesFetched = 0;
+ mLastReadPosition = 0;
+ }
+
+ while (true) {
+ synchronized (mCircularBufferMonitor) {
+ while ((mBytesFetched - mLastReadPosition + PADDING_SIZE) > CIRCULAR_BUFFER_SIZE
+ && mStreaming) {
+ try {
+ mCircularBufferMonitor.wait();
+ } catch (InterruptedException e) {
+ // Wait again.
+ Thread.currentThread().interrupt();
+ }
+ }
+ if (!mStreaming) {
+ break;
+ }
+ }
+
+ int bytesWritten = mSource.read(dataBuffer);
+ if (bytesWritten <= 0) {
+ try {
+ // When buffer is underrun, we sleep for short time to prevent
+ // unnecessary CPU draining.
+ sleep(BUFFER_UNDERRUN_SLEEP_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ continue;
+ }
+
+ mEventDetector.feedTSStream(dataBuffer, 0, bytesWritten);
+
+ synchronized (mCircularBufferMonitor) {
+ int posInBuffer = (int) (mBytesFetched % CIRCULAR_BUFFER_SIZE);
+ int bytesToCopyInFirstPass = bytesWritten;
+ if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) {
+ bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer;
+ }
+ System.arraycopy(
+ dataBuffer, 0, mCircularBuffer, posInBuffer, bytesToCopyInFirstPass);
+ if (bytesToCopyInFirstPass < bytesWritten) {
+ System.arraycopy(
+ dataBuffer,
+ bytesToCopyInFirstPass,
+ mCircularBuffer,
+ 0,
+ bytesWritten - bytesToCopyInFirstPass);
+ }
+ mBytesFetched += bytesWritten;
+ mCircularBufferMonitor.notify();
+ }
+ }
+
+ Log.i(TAG, "Streaming stopped");
+ mSource.close();
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/source/TsDataSource.java b/tuner/src/com/android/tv/tuner/source/TsDataSource.java
new file mode 100644
index 00000000..be902944
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/source/TsDataSource.java
@@ -0,0 +1,49 @@
+/*
+ * 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.source;
+
+import com.google.android.exoplayer.upstream.DataSource;
+
+/** {@link DataSource} for MPEG-TS stream, which will be used by {@link TsExtractor}. */
+public abstract class TsDataSource implements DataSource {
+
+ /**
+ * Returns the number of bytes being buffered by {@link TsStreamer} so far.
+ *
+ * @return the buffered position
+ */
+ public long getBufferedPosition() {
+ return 0;
+ }
+
+ /**
+ * Returns the offset position where the last {@link DataSource#read} read.
+ *
+ * @return the last read position
+ */
+ public long getLastReadPosition() {
+ return 0;
+ }
+
+ /**
+ * Shifts start position by the specified offset. Do not call this method when the class already
+ * provided MPEG-TS stream to the extractor.
+ *
+ * @param offset 0 <= offset <= buffered position
+ */
+ public void shiftStartPosition(long offset) {}
+}
diff --git a/tuner/src/com/android/tv/tuner/source/TsDataSourceManager.java b/tuner/src/com/android/tv/tuner/source/TsDataSourceManager.java
new file mode 100644
index 00000000..08acbc88
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/source/TsDataSourceManager.java
@@ -0,0 +1,136 @@
+/*
+ * 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.source;
+
+import android.content.Context;
+import android.support.annotation.VisibleForTesting;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.data.nano.Channel;
+import com.android.tv.tuner.tvinput.EventDetector;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Manages {@link DataSource} for playback and recording. The class hides handling of {@link
+ * TunerHal} and {@link TsStreamer} from other classes. One TsDataSourceManager should be created
+ * for per session.
+ */
+public class TsDataSourceManager {
+ private static final Object sLock = new Object();
+ private static final Map<TsDataSource, TsStreamer> sTsStreamers = new ConcurrentHashMap<>();
+
+ private static int sSequenceId;
+
+ private final int mId;
+ private final boolean mIsRecording;
+ private final TunerTsStreamerManager mTunerStreamerManager =
+ TunerTsStreamerManager.getInstance();
+
+ private boolean mKeepTuneStatus;
+
+ /**
+ * Creates TsDataSourceManager to create and release {@link DataSource} which will be used for
+ * playing and recording.
+ *
+ * @param isRecording {@code true} when for recording, {@code false} otherwise
+ * @return {@link TsDataSourceManager}
+ */
+ public static TsDataSourceManager createSourceManager(boolean isRecording) {
+ int id;
+ synchronized (sLock) {
+ id = ++sSequenceId;
+ }
+ return new TsDataSourceManager(id, isRecording);
+ }
+
+ private TsDataSourceManager(int id, boolean isRecording) {
+ mId = id;
+ mIsRecording = isRecording;
+ mKeepTuneStatus = true;
+ }
+
+ /**
+ * Creates or retrieves {@link TsDataSource} for playing or recording
+ *
+ * @param context a {@link Context} instance
+ * @param channel to play or record
+ * @param eventListener for program information which will be scanned from MPEG2-TS stream
+ * @return {@link TsDataSource} which will provide the specified channel stream
+ */
+ public TsDataSource createDataSource(
+ Context context, TunerChannel channel, EventDetector.EventListener eventListener) {
+ if (channel.getType() == Channel.TunerType.TYPE_FILE) {
+ // MPEG2 TS captured stream file recording is not supported.
+ if (mIsRecording) {
+ return null;
+ }
+ FileTsStreamer streamer = new FileTsStreamer(eventListener, context);
+ if (streamer.startStream(channel)) {
+ TsDataSource source = streamer.createDataSource();
+ sTsStreamers.put(source, streamer);
+ return source;
+ }
+ return null;
+ }
+ return mTunerStreamerManager.createDataSource(
+ context, channel, eventListener, mId, !mIsRecording && mKeepTuneStatus);
+ }
+
+ /**
+ * Releases the specified {@link TsDataSource} and underlying {@link TunerHal}.
+ *
+ * @param source to release
+ */
+ public void releaseDataSource(TsDataSource source) {
+ if (source instanceof TunerTsStreamer.TunerDataSource) {
+ mTunerStreamerManager.releaseDataSource(source, mId, !mIsRecording && mKeepTuneStatus);
+ } else if (source instanceof FileTsStreamer.FileDataSource) {
+ FileTsStreamer streamer = (FileTsStreamer) sTsStreamers.get(source);
+ if (streamer != null) {
+ sTsStreamers.remove(source);
+ streamer.stopStream();
+ }
+ }
+ }
+
+ /** Indicates that the current session has pending tunes. */
+ public void setHasPendingTune() {
+ mTunerStreamerManager.setHasPendingTune(mId);
+ }
+
+ /**
+ * Indicates whether the underlying {@link TunerHal} should be kept or not when data source is
+ * being released. TODO: If b/30750953 is fixed, we can remove this function.
+ *
+ * @param keepTuneStatus underlying {@link TunerHal} will be reused when data source releasing.
+ */
+ public void setKeepTuneStatus(boolean keepTuneStatus) {
+ mKeepTuneStatus = keepTuneStatus;
+ }
+
+ /** Add tuner hal into TunerTsStreamerManager for test. */
+ @VisibleForTesting
+ public void addTunerHalForTest(TunerHal tunerHal) {
+ mTunerStreamerManager.addTunerHal(tunerHal, mId);
+ }
+
+ /** Releases persistent resources. */
+ public void release() {
+ mTunerStreamerManager.release(mId);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/source/TsStreamWriter.java b/tuner/src/com/android/tv/tuner/source/TsStreamWriter.java
new file mode 100644
index 00000000..f90136bf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/source/TsStreamWriter.java
@@ -0,0 +1,238 @@
+/*
+ * 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.source;
+
+import android.content.Context;
+import android.util.Log;
+import com.android.tv.tuner.data.TunerChannel;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Stores TS files to the disk for debugging. */
+public class TsStreamWriter {
+ private static final String TAG = "TsStreamWriter";
+ private static final boolean DEBUG = false;
+
+ private static final long TIME_LIMIT_MS = 10000; // 10s
+ private static final int NO_INSTANCE_ID = 0;
+ private static final int MAX_GET_ID_RETRY_COUNT = 5;
+ private static final int MAX_INSTANCE_ID = 10000;
+ private static final String SEPARATOR = "_";
+
+ private FileOutputStream mFileOutputStream;
+ private long mFileStartTimeMs;
+ private String mFileName = null;
+ private final String mDirectoryPath;
+ private final File mDirectory;
+ private final int mInstanceId;
+ private TunerChannel mChannel;
+
+ public TsStreamWriter(Context context) {
+ File externalFilesDir = context.getExternalFilesDir(null);
+ if (externalFilesDir == null || !externalFilesDir.isDirectory()) {
+ mDirectoryPath = null;
+ mDirectory = null;
+ mInstanceId = NO_INSTANCE_ID;
+ if (DEBUG) {
+ Log.w(TAG, "Fail to get external files dir!");
+ }
+ } else {
+ mDirectoryPath = externalFilesDir.getPath() + "/EngTsStream";
+ mDirectory = new File(mDirectoryPath);
+ if (!mDirectory.exists()) {
+ boolean madeDir = mDirectory.mkdir();
+ if (!madeDir) {
+ Log.w(TAG, "Error. Fail to create folder!");
+ }
+ }
+ mInstanceId = generateInstanceId();
+ }
+ }
+
+ /**
+ * Sets the current channel.
+ *
+ * @param channel curren channel of the stream
+ */
+ public void setChannel(TunerChannel channel) {
+ mChannel = channel;
+ }
+
+ /** Opens a file to store TS data. */
+ public void openFile() {
+ if (mChannel == null || mDirectoryPath == null) {
+ return;
+ }
+ mFileStartTimeMs = System.currentTimeMillis();
+ mFileName =
+ mChannel.getDisplayNumber()
+ + SEPARATOR
+ + mFileStartTimeMs
+ + SEPARATOR
+ + mInstanceId
+ + ".ts";
+ String filePath = mDirectoryPath + "/" + mFileName;
+ try {
+ mFileOutputStream = new FileOutputStream(filePath, false);
+ } catch (FileNotFoundException e) {
+ Log.w(TAG, "Cannot open file: " + filePath, e);
+ }
+ }
+
+ /**
+ * Closes the file and stops storing TS data.
+ *
+ * @param calledWhenStopStream {@code true} if this method is called when the stream is stopped
+ * {@code false} otherwise
+ */
+ public void closeFile(boolean calledWhenStopStream) {
+ if (mFileOutputStream == null) {
+ return;
+ }
+ try {
+ mFileOutputStream.close();
+ deleteOutdatedFiles(calledWhenStopStream);
+ mFileName = null;
+ mFileOutputStream = null;
+ } catch (IOException e) {
+ Log.w(TAG, "Error on closing file.", e);
+ }
+ }
+
+ /**
+ * Writes the data to the file.
+ *
+ * @param buffer the data to be written
+ * @param bytesWritten number of bytes written
+ */
+ public void writeToFile(byte[] buffer, int bytesWritten) {
+ if (mFileOutputStream == null) {
+ return;
+ }
+ if (System.currentTimeMillis() - mFileStartTimeMs > TIME_LIMIT_MS) {
+ closeFile(false);
+ openFile();
+ }
+ try {
+ mFileOutputStream.write(buffer, 0, bytesWritten);
+ } catch (IOException e) {
+ Log.w(TAG, "Error on writing TS stream.", e);
+ }
+ }
+
+ /**
+ * Deletes outdated files to save storage.
+ *
+ * @param deleteAll {@code true} if all the files with the relative ID should be deleted {@code
+ * false} if the most recent file should not be deleted
+ */
+ private void deleteOutdatedFiles(boolean deleteAll) {
+ if (mFileName == null) {
+ return;
+ }
+ if (mDirectory == null || !mDirectory.isDirectory()) {
+ Log.e(TAG, "Error. The folder doesn't exist!");
+ return;
+ }
+ if (mFileName == null) {
+ Log.e(TAG, "Error. The current file name is null!");
+ return;
+ }
+ for (File file : mDirectory.listFiles()) {
+ if (file.isFile()
+ && getFileId(file) == mInstanceId
+ && (deleteAll || !mFileName.equals(file.getName()))) {
+ boolean deleted = file.delete();
+ if (DEBUG && !deleted) {
+ Log.w(TAG, "Failed to delete " + file.getName());
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates a unique instance ID.
+ *
+ * @return a unique instance ID
+ */
+ private int generateInstanceId() {
+ if (mDirectory == null) {
+ return NO_INSTANCE_ID;
+ }
+ Set<Integer> idSet = getExistingIds();
+ if (idSet == null) {
+ return NO_INSTANCE_ID;
+ }
+ for (int i = 0; i < MAX_GET_ID_RETRY_COUNT; i++) {
+ // Range [1, MAX_INSTANCE_ID]
+ int id = (int) Math.floor(Math.random() * MAX_INSTANCE_ID) + 1;
+ if (!idSet.contains(id)) {
+ return id;
+ }
+ }
+ return NO_INSTANCE_ID;
+ }
+
+ /**
+ * Gets all existing instance IDs.
+ *
+ * @return a set of all existing instance IDs
+ */
+ private Set<Integer> getExistingIds() {
+ if (mDirectory == null || !mDirectory.isDirectory()) {
+ return null;
+ }
+
+ Set<Integer> idSet = new HashSet<>();
+ for (File file : mDirectory.listFiles()) {
+ int id = getFileId(file);
+ if (id != NO_INSTANCE_ID) {
+ idSet.add(id);
+ }
+ }
+ return idSet;
+ }
+
+ /**
+ * Gets the instance ID of a given file.
+ *
+ * @param file the file whose TsStreamWriter ID is returned
+ * @return the TsStreamWriter ID of the file or NO_INSTANCE_ID if not available
+ */
+ private static int getFileId(File file) {
+ if (file == null || !file.isFile()) {
+ return NO_INSTANCE_ID;
+ }
+ String fileName = file.getName();
+ int lastSeparator = fileName.lastIndexOf(SEPARATOR);
+ if (!fileName.endsWith(".ts") || lastSeparator == -1) {
+ return NO_INSTANCE_ID;
+ }
+ try {
+ return Integer.parseInt(fileName.substring(lastSeparator + 1, fileName.length() - 3));
+ } catch (NumberFormatException e) {
+ if (DEBUG) {
+ Log.e(TAG, fileName + " is not a valid file name.");
+ }
+ }
+ return NO_INSTANCE_ID;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/source/TsStreamer.java b/tuner/src/com/android/tv/tuner/source/TsStreamer.java
new file mode 100644
index 00000000..3dbba7e7
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/source/TsStreamer.java
@@ -0,0 +1,53 @@
+/*
+ * 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.source;
+
+import com.android.tv.tuner.ChannelScanFileParser;
+import com.android.tv.tuner.data.TunerChannel;
+
+/**
+ * Interface definition for a stream generator. The interface will provide streams for scanning
+ * channels and/or playback.
+ */
+public interface TsStreamer {
+ /**
+ * Starts streaming the data for channel scanning process.
+ *
+ * @param channel {@link ChannelScanFileParser.ScanChannel} to be scanned
+ * @return {@code true} if ready to stream, otherwise {@code false}
+ */
+ boolean startStream(ChannelScanFileParser.ScanChannel channel);
+
+ /**
+ * Starts streaming the data for channel playing or recording.
+ *
+ * @param channel {@link TunerChannel} to tune
+ * @return {@code true} if ready to stream, otherwise {@code false}
+ */
+ boolean startStream(TunerChannel channel);
+
+ /** Stops streaming the data. */
+ void stopStream();
+
+ /**
+ * Creates {@link TsDataSource} which will provide MPEG-2 TS stream for {@link
+ * android.media.MediaExtractor}. The source will start from the position where it is created.
+ *
+ * @return {@link TsDataSource}
+ */
+ TsDataSource createDataSource();
+}
diff --git a/tuner/src/com/android/tv/tuner/source/TunerTsStreamer.java b/tuner/src/com/android/tv/tuner/source/TunerTsStreamer.java
new file mode 100644
index 00000000..21b7a1f8
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/source/TunerTsStreamer.java
@@ -0,0 +1,420 @@
+/*
+ * 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.source;
+
+import android.content.Context;
+import android.util.Log;
+import android.util.Pair;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.ChannelScanFileParser;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.tvinput.EventDetector;
+import com.android.tv.tuner.tvinput.EventDetector.EventListener;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.upstream.DataSpec;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+/** Provides MPEG-2 TS stream sources for channel playing from an underlying tuner device. */
+public class TunerTsStreamer implements TsStreamer {
+ private static final String TAG = "TunerTsStreamer";
+
+ private static final int MIN_READ_UNIT = 1500;
+ private static final int READ_BUFFER_SIZE = MIN_READ_UNIT * 10; // ~15KB
+ private static final int CIRCULAR_BUFFER_SIZE = MIN_READ_UNIT * 20000; // ~ 30MB
+ private static final int TS_PACKET_SIZE = 188;
+
+ private static final int READ_TIMEOUT_MS = 5000; // 5 secs.
+ private static final int BUFFER_UNDERRUN_SLEEP_MS = 10;
+ private static final int READ_ERROR_STREAMING_ENDED = -1;
+ private static final int READ_ERROR_BUFFER_OVERWRITTEN = -2;
+
+ private final Object mCircularBufferMonitor = new Object();
+ private final byte[] mCircularBuffer = new byte[CIRCULAR_BUFFER_SIZE];
+ private long mBytesFetched;
+ private final AtomicLong mLastReadPosition = new AtomicLong();
+ private boolean mStreaming;
+
+ private final TunerHal mTunerHal;
+ private TunerChannel mChannel;
+ private Thread mStreamingThread;
+ private final EventDetector mEventDetector;
+ private final List<Pair<EventListener, Boolean>> mEventListenerActions = new ArrayList<>();
+
+ private final TsStreamWriter mTsStreamWriter;
+ private String mChannelNumber;
+
+ public static class TunerDataSource extends TsDataSource {
+ private final TunerTsStreamer mTsStreamer;
+ private final AtomicLong mLastReadPosition = new AtomicLong(0);
+ private long mStartBufferedPosition;
+
+ private TunerDataSource(TunerTsStreamer tsStreamer) {
+ mTsStreamer = tsStreamer;
+ mStartBufferedPosition = tsStreamer.getBufferedPosition();
+ }
+
+ @Override
+ public long getBufferedPosition() {
+ return mTsStreamer.getBufferedPosition() - mStartBufferedPosition;
+ }
+
+ @Override
+ public long getLastReadPosition() {
+ return mLastReadPosition.get();
+ }
+
+ @Override
+ public void shiftStartPosition(long offset) {
+ SoftPreconditions.checkState(mLastReadPosition.get() == 0);
+ SoftPreconditions.checkArgument(0 <= offset && offset <= getBufferedPosition());
+ mStartBufferedPosition += offset;
+ }
+
+ @Override
+ public long open(DataSpec dataSpec) throws IOException {
+ mLastReadPosition.set(0);
+ return C.LENGTH_UNBOUNDED;
+ }
+
+ @Override
+ public void close() {}
+
+ @Override
+ public int read(byte[] buffer, int offset, int readLength) throws IOException {
+ int ret =
+ mTsStreamer.readAt(
+ mStartBufferedPosition + mLastReadPosition.get(),
+ buffer,
+ offset,
+ readLength);
+ if (ret > 0) {
+ mLastReadPosition.addAndGet(ret);
+ } else if (ret == READ_ERROR_BUFFER_OVERWRITTEN) {
+ long currentPosition = mStartBufferedPosition + mLastReadPosition.get();
+ long endPosition = mTsStreamer.getBufferedPosition();
+ long diff =
+ ((endPosition - currentPosition + TS_PACKET_SIZE - 1) / TS_PACKET_SIZE)
+ * TS_PACKET_SIZE;
+ Log.w(TAG, "Demux position jump by overwritten buffer: " + diff);
+ mStartBufferedPosition = currentPosition + diff;
+ mLastReadPosition.set(0);
+ return 0;
+ }
+ return ret;
+ }
+ }
+ /**
+ * Creates {@link TsStreamer} for playing or recording the specified channel.
+ *
+ * @param tunerHal the HAL for tuner device
+ * @param eventListener the listener for channel & program information
+ */
+ public TunerTsStreamer(TunerHal tunerHal, EventListener eventListener, Context context) {
+ mTunerHal = tunerHal;
+ mEventDetector = new EventDetector(mTunerHal);
+ if (eventListener != null) {
+ mEventDetector.registerListener(eventListener);
+ }
+ mTsStreamWriter =
+ context != null && TunerPreferences.getStoreTsStream(context)
+ ? new TsStreamWriter(context)
+ : null;
+ }
+
+ public TunerTsStreamer(TunerHal tunerHal, EventListener eventListener) {
+ this(tunerHal, eventListener, null);
+ }
+
+ @Override
+ public boolean startStream(TunerChannel channel) {
+ if (mTunerHal.tune(
+ channel.getFrequency(), channel.getModulation(), channel.getDisplayNumber(false))) {
+ if (channel.hasVideo()) {
+ mTunerHal.addPidFilter(channel.getVideoPid(), TunerHal.FILTER_TYPE_VIDEO);
+ }
+ boolean audioFilterSet = false;
+ for (Integer audioPid : channel.getAudioPids()) {
+ if (!audioFilterSet) {
+ mTunerHal.addPidFilter(audioPid, TunerHal.FILTER_TYPE_AUDIO);
+ audioFilterSet = true;
+ } else {
+ // FILTER_TYPE_AUDIO overrides the previous filter for audio. We use
+ // FILTER_TYPE_OTHER from the secondary one to get the all audio tracks.
+ mTunerHal.addPidFilter(audioPid, TunerHal.FILTER_TYPE_OTHER);
+ }
+ }
+ mTunerHal.addPidFilter(channel.getPcrPid(), TunerHal.FILTER_TYPE_PCR);
+ if (mEventDetector != null) {
+ mEventDetector.startDetecting(
+ channel.getFrequency(),
+ channel.getModulation(),
+ channel.getProgramNumber());
+ }
+ mChannel = channel;
+ mChannelNumber = channel.getDisplayNumber();
+ synchronized (mCircularBufferMonitor) {
+ if (mStreaming) {
+ Log.w(TAG, "Streaming should be stopped before start streaming");
+ return true;
+ }
+ mStreaming = true;
+ mBytesFetched = 0;
+ mLastReadPosition.set(0L);
+ }
+ if (mTsStreamWriter != null) {
+ mTsStreamWriter.setChannel(mChannel);
+ mTsStreamWriter.openFile();
+ }
+ mStreamingThread = new StreamingThread();
+ mStreamingThread.start();
+ Log.i(TAG, "Streaming started");
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean startStream(ChannelScanFileParser.ScanChannel channel) {
+ if (mTunerHal.tune(channel.frequency, channel.modulation, null)) {
+ mEventDetector.startDetecting(
+ channel.frequency, channel.modulation, EventDetector.ALL_PROGRAM_NUMBERS);
+ synchronized (mCircularBufferMonitor) {
+ if (mStreaming) {
+ Log.w(TAG, "Streaming should be stopped before start streaming");
+ return true;
+ }
+ mStreaming = true;
+ mBytesFetched = 0;
+ mLastReadPosition.set(0L);
+ }
+ mStreamingThread = new StreamingThread();
+ mStreamingThread.start();
+ Log.i(TAG, "Streaming started");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Blocks the current thread until the streaming thread stops. In rare cases when the tuner
+ * device is overloaded this can take a while, but usually it returns pretty quickly.
+ */
+ @Override
+ public void stopStream() {
+ mChannel = null;
+ synchronized (mCircularBufferMonitor) {
+ mStreaming = false;
+ mCircularBufferMonitor.notifyAll();
+ }
+
+ try {
+ if (mStreamingThread != null) {
+ mStreamingThread.join();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ if (mTsStreamWriter != null) {
+ mTsStreamWriter.closeFile(true);
+ mTsStreamWriter.setChannel(null);
+ }
+ }
+
+ @Override
+ public TsDataSource createDataSource() {
+ return new TunerDataSource(this);
+ }
+
+ /**
+ * Returns incomplete channel lists which was scanned so far. Incomplete channel means the
+ * channel whose channel information is not complete or is not well-formed.
+ *
+ * @return {@link List} of {@link TunerChannel}
+ */
+ public List<TunerChannel> getMalFormedChannels() {
+ return mEventDetector.getMalFormedChannels();
+ }
+
+ /**
+ * Returns the current {@link TunerHal} which provides MPEG-TS stream for TunerTsStreamer.
+ *
+ * @return {@link TunerHal}
+ */
+ public TunerHal getTunerHal() {
+ return mTunerHal;
+ }
+
+ /**
+ * Returns the current tuned channel for TunerTsStreamer.
+ *
+ * @return {@link TunerChannel}
+ */
+ public TunerChannel getChannel() {
+ return mChannel;
+ }
+
+ /**
+ * Returns the current buffered position from tuner.
+ *
+ * @return the current buffered position
+ */
+ public long getBufferedPosition() {
+ synchronized (mCircularBufferMonitor) {
+ return mBytesFetched;
+ }
+ }
+
+ public String getStreamerInfo() {
+ return "Channel: " + mChannelNumber + ", Streaming: " + mStreaming;
+ }
+
+ public void registerListener(EventListener listener) {
+ if (mEventDetector != null && listener != null) {
+ synchronized (mEventListenerActions) {
+ mEventListenerActions.add(new Pair<>(listener, true));
+ }
+ }
+ }
+
+ public void unregisterListener(EventListener listener) {
+ if (mEventDetector != null) {
+ synchronized (mEventListenerActions) {
+ mEventListenerActions.add(new Pair(listener, false));
+ }
+ }
+ }
+
+ private class StreamingThread extends Thread {
+ @Override
+ public void run() {
+ // Buffers for streaming data from the tuner and the internal buffer.
+ byte[] dataBuffer = new byte[READ_BUFFER_SIZE];
+
+ while (true) {
+ synchronized (mCircularBufferMonitor) {
+ if (!mStreaming) {
+ break;
+ }
+ }
+
+ if (mEventDetector != null) {
+ synchronized (mEventListenerActions) {
+ for (Pair listenerAction : mEventListenerActions) {
+ EventListener listener = (EventListener) listenerAction.first;
+ if ((boolean) listenerAction.second) {
+ mEventDetector.registerListener(listener);
+ } else {
+ mEventDetector.unregisterListener(listener);
+ }
+ }
+ mEventListenerActions.clear();
+ }
+ }
+
+ int bytesWritten = mTunerHal.readTsStream(dataBuffer, dataBuffer.length);
+ if (bytesWritten <= 0) {
+ try {
+ // When buffer is underrun, we sleep for short time to prevent
+ // unnecessary CPU draining.
+ sleep(BUFFER_UNDERRUN_SLEEP_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ continue;
+ }
+
+ if (mTsStreamWriter != null) {
+ mTsStreamWriter.writeToFile(dataBuffer, bytesWritten);
+ }
+
+ if (mEventDetector != null) {
+ mEventDetector.feedTSStream(dataBuffer, 0, bytesWritten);
+ }
+ synchronized (mCircularBufferMonitor) {
+ int posInBuffer = (int) (mBytesFetched % CIRCULAR_BUFFER_SIZE);
+ int bytesToCopyInFirstPass = bytesWritten;
+ if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) {
+ bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer;
+ }
+ System.arraycopy(
+ dataBuffer, 0, mCircularBuffer, posInBuffer, bytesToCopyInFirstPass);
+ if (bytesToCopyInFirstPass < bytesWritten) {
+ System.arraycopy(
+ dataBuffer,
+ bytesToCopyInFirstPass,
+ mCircularBuffer,
+ 0,
+ bytesWritten - bytesToCopyInFirstPass);
+ }
+ mBytesFetched += bytesWritten;
+ mCircularBufferMonitor.notifyAll();
+ }
+ }
+
+ Log.i(TAG, "Streaming stopped");
+ }
+ }
+
+ /**
+ * Reads data from internal buffer.
+ *
+ * @param pos the position to read from
+ * @param buffer to read
+ * @param offset start position of the read buffer
+ * @param amount number of bytes to read
+ * @return number of read bytes when successful, {@code -1} otherwise
+ * @throws IOException
+ */
+ public int readAt(long pos, byte[] buffer, int offset, int amount) throws IOException {
+ while (true) {
+ synchronized (mCircularBufferMonitor) {
+ if (!mStreaming) {
+ return READ_ERROR_STREAMING_ENDED;
+ }
+ if (mBytesFetched - CIRCULAR_BUFFER_SIZE > pos) {
+ Log.w(TAG, "Demux is requesting the data which is already overwritten.");
+ return READ_ERROR_BUFFER_OVERWRITTEN;
+ }
+ if (mBytesFetched < pos + amount) {
+ try {
+ mCircularBufferMonitor.wait(READ_TIMEOUT_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ // Try again to prevent starvation.
+ // Give chances to read from other threads.
+ continue;
+ }
+ int startPos = (int) (pos % CIRCULAR_BUFFER_SIZE);
+ int endPos = (int) ((pos + amount) % CIRCULAR_BUFFER_SIZE);
+ int firstLength = (startPos > endPos ? CIRCULAR_BUFFER_SIZE : endPos) - startPos;
+ System.arraycopy(mCircularBuffer, startPos, buffer, offset, firstLength);
+ if (firstLength < amount) {
+ System.arraycopy(
+ mCircularBuffer, 0, buffer, offset + firstLength, amount - firstLength);
+ }
+ mCircularBufferMonitor.notifyAll();
+ return amount;
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/source/TunerTsStreamerManager.java b/tuner/src/com/android/tv/tuner/source/TunerTsStreamerManager.java
new file mode 100644
index 00000000..44fb41e6
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/source/TunerTsStreamerManager.java
@@ -0,0 +1,303 @@
+/*
+ * 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.source;
+
+import android.content.Context;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.util.AutoCloseableUtils;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.tvinput.EventDetector;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Manages {@link TunerTsStreamer} for playback and recording. The class hides handling of {@link
+ * TunerHal} from other classes. This class is used by {@link TsDataSourceManager}. Don't use this
+ * class directly.
+ */
+class TunerTsStreamerManager {
+ // The lock will protect mStreamerFinder, mSourceToStreamerMap and some part of TsStreamCreator
+ // to support timely {@link TunerTsStreamer} cancellation due to a new tune request from
+ // the same session.
+ private final Object mCancelLock = new Object();
+ private final StreamerFinder mStreamerFinder = new StreamerFinder();
+ private final Map<Integer, TsStreamerCreator> mCreators = new HashMap<>();
+ private final Map<Integer, EventDetector.EventListener> mListeners = new HashMap<>();
+ private final Map<TsDataSource, TunerTsStreamer> mSourceToStreamerMap = new HashMap<>();
+ private final TunerHalManager mTunerHalManager = new TunerHalManager();
+ private static TunerTsStreamerManager sInstance;
+
+ /**
+ * Returns the singleton instance for the class
+ *
+ * @return TunerTsStreamerManager
+ */
+ static synchronized TunerTsStreamerManager getInstance() {
+ if (sInstance == null) {
+ sInstance = new TunerTsStreamerManager();
+ }
+ return sInstance;
+ }
+
+ private TunerTsStreamerManager() {}
+
+ synchronized TsDataSource createDataSource(
+ Context context,
+ TunerChannel channel,
+ EventDetector.EventListener listener,
+ int sessionId,
+ boolean reuse) {
+ TsStreamerCreator creator;
+ synchronized (mCancelLock) {
+ if (mStreamerFinder.containsLocked(channel)) {
+ mStreamerFinder.appendSessionLocked(channel, sessionId);
+ TunerTsStreamer streamer = mStreamerFinder.getStreamerLocked(channel);
+ TsDataSource source = streamer.createDataSource();
+ mListeners.put(sessionId, listener);
+ streamer.registerListener(listener);
+ mSourceToStreamerMap.put(source, streamer);
+ return source;
+ }
+ creator = new TsStreamerCreator(context, channel, listener);
+ mCreators.put(sessionId, creator);
+ }
+ TunerTsStreamer streamer = creator.create(sessionId, reuse);
+ synchronized (mCancelLock) {
+ mCreators.remove(sessionId);
+ if (streamer == null) {
+ return null;
+ }
+ if (!creator.isCancelledLocked()) {
+ mStreamerFinder.putLocked(channel, sessionId, streamer);
+ TsDataSource source = streamer.createDataSource();
+ mListeners.put(sessionId, listener);
+ mSourceToStreamerMap.put(source, streamer);
+ return source;
+ }
+ }
+ // Created streamer was cancelled by a new tune request.
+ streamer.stopStream();
+ TunerHal hal = streamer.getTunerHal();
+ hal.setHasPendingTune(false);
+ mTunerHalManager.releaseTunerHal(hal, sessionId, reuse);
+ return null;
+ }
+
+ synchronized void releaseDataSource(TsDataSource source, int sessionId, boolean reuse) {
+ TunerTsStreamer streamer;
+ synchronized (mCancelLock) {
+ streamer = mSourceToStreamerMap.get(source);
+ mSourceToStreamerMap.remove(source);
+ if (streamer == null) {
+ return;
+ }
+ EventDetector.EventListener listener = mListeners.remove(sessionId);
+ streamer.unregisterListener(listener);
+ TunerChannel channel = streamer.getChannel();
+ SoftPreconditions.checkState(channel != null);
+ mStreamerFinder.removeSessionLocked(channel, sessionId);
+ if (mStreamerFinder.containsLocked(channel)) {
+ return;
+ }
+ }
+ streamer.stopStream();
+ TunerHal hal = streamer.getTunerHal();
+ hal.setHasPendingTune(false);
+ mTunerHalManager.releaseTunerHal(hal, sessionId, reuse);
+ }
+
+ void setHasPendingTune(int sessionId) {
+ synchronized (mCancelLock) {
+ if (mCreators.containsKey(sessionId)) {
+ mCreators.get(sessionId).cancelLocked();
+ }
+ }
+ }
+
+ /** Add tuner hal into TunerHalManager for test. */
+ void addTunerHal(TunerHal tunerHal, int sessionId) {
+ mTunerHalManager.addTunerHal(tunerHal, sessionId);
+ }
+
+ synchronized void release(int sessionId) {
+ mTunerHalManager.releaseCachedHal(sessionId);
+ }
+
+ private static class StreamerFinder {
+ private final Map<TunerChannel, Set<Integer>> mSessions = new HashMap<>();
+ private final Map<TunerChannel, TunerTsStreamer> mStreamers = new HashMap<>();
+
+ // @GuardedBy("mCancelLock")
+ private void putLocked(TunerChannel channel, int sessionId, TunerTsStreamer streamer) {
+ Set<Integer> sessions = new HashSet<>();
+ sessions.add(sessionId);
+ mSessions.put(channel, sessions);
+ mStreamers.put(channel, streamer);
+ }
+
+ // @GuardedBy("mCancelLock")
+ private void appendSessionLocked(TunerChannel channel, int sessionId) {
+ if (mSessions.containsKey(channel)) {
+ mSessions.get(channel).add(sessionId);
+ }
+ }
+
+ // @GuardedBy("mCancelLock")
+ private void removeSessionLocked(TunerChannel channel, int sessionId) {
+ Set<Integer> sessions = mSessions.get(channel);
+ sessions.remove(sessionId);
+ if (sessions.size() == 0) {
+ mSessions.remove(channel);
+ mStreamers.remove(channel);
+ }
+ }
+
+ // @GuardedBy("mCancelLock")
+ private boolean containsLocked(TunerChannel channel) {
+ return mSessions.containsKey(channel);
+ }
+
+ // @GuardedBy("mCancelLock")
+ private TunerTsStreamer getStreamerLocked(TunerChannel channel) {
+ return mStreamers.containsKey(channel) ? mStreamers.get(channel) : null;
+ }
+ }
+
+ /**
+ * {@link TunerTsStreamer} creation can be cancelled by a new tune request for the same session.
+ * The class supports the cancellation in creating new {@link TunerTsStreamer}.
+ */
+ private class TsStreamerCreator {
+ private final Context mContext;
+ private final TunerChannel mChannel;
+ private final EventDetector.EventListener mEventListener;
+ // mCancelled will be {@code true} if a new tune request for the same session
+ // cancels create().
+ private boolean mCancelled;
+ private TunerHal mTunerHal;
+
+ private TsStreamerCreator(
+ Context context, TunerChannel channel, EventDetector.EventListener listener) {
+ mContext = context;
+ mChannel = channel;
+ mEventListener = listener;
+ }
+
+ private TunerTsStreamer create(int sessionId, boolean reuse) {
+ TunerHal hal = mTunerHalManager.getOrCreateTunerHal(mContext, sessionId);
+ if (hal == null) {
+ return null;
+ }
+ boolean canceled = false;
+ synchronized (mCancelLock) {
+ if (!mCancelled) {
+ mTunerHal = hal;
+ } else {
+ canceled = true;
+ }
+ }
+ if (!canceled) {
+ TunerTsStreamer tsStreamer = new TunerTsStreamer(hal, mEventListener, mContext);
+ if (tsStreamer.startStream(mChannel)) {
+ return tsStreamer;
+ }
+ synchronized (mCancelLock) {
+ mTunerHal = null;
+ }
+ }
+ hal.setHasPendingTune(false);
+ // Since TunerTsStreamer is not properly created, closes TunerHal.
+ // And do not re-use TunerHal when it is not cancelled.
+ mTunerHalManager.releaseTunerHal(hal, sessionId, mCancelled && reuse);
+ return null;
+ }
+
+ // @GuardedBy("mCancelLock")
+ private void cancelLocked() {
+ if (mCancelled) {
+ return;
+ }
+ mCancelled = true;
+ if (mTunerHal != null) {
+ mTunerHal.setHasPendingTune(true);
+ }
+ }
+
+ // @GuardedBy("mCancelLock")
+ private boolean isCancelledLocked() {
+ return mCancelled;
+ }
+ }
+
+ /**
+ * Supports sharing {@link TunerHal} among multiple sessions. The class also supports session
+ * affinity for {@link TunerHal} allocation.
+ */
+ private static class TunerHalManager {
+ private final Map<Integer, TunerHal> mTunerHals = new HashMap<>();
+
+ private TunerHal getOrCreateTunerHal(Context context, int sessionId) {
+ // Handles session affinity.
+ TunerHal hal = mTunerHals.get(sessionId);
+ if (hal != null) {
+ mTunerHals.remove(sessionId);
+ return hal;
+ }
+ // Finds a TunerHal which is cached for other sessions.
+ Iterator it = mTunerHals.keySet().iterator();
+ if (it.hasNext()) {
+ Integer key = (Integer) it.next();
+ hal = mTunerHals.get(key);
+ mTunerHals.remove(key);
+ return hal;
+ }
+ return TunerHal.createInstance(context);
+ }
+
+ private void releaseTunerHal(TunerHal hal, int sessionId, boolean reuse) {
+ if (!reuse || !hal.isReusable()) {
+ AutoCloseableUtils.closeQuietly(hal);
+ return;
+ }
+ TunerHal cachedHal = mTunerHals.get(sessionId);
+ if (cachedHal != hal) {
+ mTunerHals.put(sessionId, hal);
+ if (cachedHal != null) {
+ AutoCloseableUtils.closeQuietly(cachedHal);
+ }
+ }
+ }
+
+ private void releaseCachedHal(int sessionId) {
+ TunerHal hal = mTunerHals.get(sessionId);
+ if (hal != null) {
+ mTunerHals.remove(sessionId);
+ }
+ if (hal != null) {
+ AutoCloseableUtils.closeQuietly(hal);
+ }
+ }
+
+ private void addTunerHal(TunerHal tunerHal, int sessionId) {
+ mTunerHals.put(sessionId, tunerHal);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/ts/SectionParser.java b/tuner/src/com/android/tv/tuner/ts/SectionParser.java
new file mode 100644
index 00000000..27726c02
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/ts/SectionParser.java
@@ -0,0 +1,2094 @@
+/*
+ * 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.ts;
+
+import android.media.tv.TvContentRating;
+import android.media.tv.TvContract.Programs.Genres;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.android.tv.tuner.data.PsiData.PatItem;
+import com.android.tv.tuner.data.PsiData.PmtItem;
+import com.android.tv.tuner.data.PsipData.Ac3AudioDescriptor;
+import com.android.tv.tuner.data.PsipData.CaptionServiceDescriptor;
+import com.android.tv.tuner.data.PsipData.ContentAdvisoryDescriptor;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.PsipData.EttItem;
+import com.android.tv.tuner.data.PsipData.ExtendedChannelNameDescriptor;
+import com.android.tv.tuner.data.PsipData.GenreDescriptor;
+import com.android.tv.tuner.data.PsipData.Iso639LanguageDescriptor;
+import com.android.tv.tuner.data.PsipData.MgtItem;
+import com.android.tv.tuner.data.PsipData.ParentalRatingDescriptor;
+import com.android.tv.tuner.data.PsipData.PsipSection;
+import com.android.tv.tuner.data.PsipData.RatingRegion;
+import com.android.tv.tuner.data.PsipData.RegionalRating;
+import com.android.tv.tuner.data.PsipData.SdtItem;
+import com.android.tv.tuner.data.PsipData.ServiceDescriptor;
+import com.android.tv.tuner.data.PsipData.ShortEventDescriptor;
+import com.android.tv.tuner.data.PsipData.TsDescriptor;
+import com.android.tv.tuner.data.PsipData.VctItem;
+import com.android.tv.tuner.data.nano.Channel;
+import com.android.tv.tuner.data.nano.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import com.android.tv.tuner.util.ByteArrayBuffer;
+import com.android.tv.tuner.util.ConvertUtils;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Parses ATSC PSIP sections. */
+public class SectionParser {
+ private static final String TAG = "SectionParser";
+ private static final boolean DEBUG = false;
+
+ private static final byte TABLE_ID_PAT = (byte) 0x00;
+ private static final byte TABLE_ID_PMT = (byte) 0x02;
+ private static final byte TABLE_ID_MGT = (byte) 0xc7;
+ private static final byte TABLE_ID_TVCT = (byte) 0xc8;
+ private static final byte TABLE_ID_CVCT = (byte) 0xc9;
+ private static final byte TABLE_ID_EIT = (byte) 0xcb;
+ private static final byte TABLE_ID_ETT = (byte) 0xcc;
+
+ // Table id for DVB
+ private static final byte TABLE_ID_SDT = (byte) 0x42;
+ private static final byte TABLE_ID_DVB_ACTUAL_P_F_EIT = (byte) 0x4e;
+ private static final byte TABLE_ID_DVB_OTHER_P_F_EIT = (byte) 0x4f;
+ private static final byte TABLE_ID_DVB_ACTUAL_SCHEDULE_EIT = (byte) 0x50;
+ private static final byte TABLE_ID_DVB_OTHER_SCHEDULE_EIT = (byte) 0x60;
+
+ // For details of the structure for the tags of descriptors, see ATSC A/65 Table 6.25.
+ public static final int DESCRIPTOR_TAG_ISO639LANGUAGE = 0x0a;
+ public static final int DESCRIPTOR_TAG_CAPTION_SERVICE = 0x86;
+ public static final int DESCRIPTOR_TAG_CONTENT_ADVISORY = 0x87;
+ public static final int DESCRIPTOR_TAG_AC3_AUDIO_STREAM = 0x81;
+ public static final int DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME = 0xa0;
+ public static final int DESCRIPTOR_TAG_GENRE = 0xab;
+
+ // For details of the structure for the tags of DVB descriptors, see DVB Document A038 Table 12.
+ public static final int DVB_DESCRIPTOR_TAG_SERVICE = 0x48;
+ public static final int DVB_DESCRIPTOR_TAG_SHORT_EVENT = 0X4d;
+ public static final int DVB_DESCRIPTOR_TAG_CONTENT = 0x54;
+ public static final int DVB_DESCRIPTOR_TAG_PARENTAL_RATING = 0x55;
+
+ private static final byte COMPRESSION_TYPE_NO_COMPRESSION = (byte) 0x00;
+ private static final byte MODE_SELECTED_UNICODE_RANGE_1 = (byte) 0x00; // 0x0000 - 0x00ff
+ private static final byte MODE_UTF16 = (byte) 0x3f;
+ private static final byte MODE_SCSU = (byte) 0x3e;
+ private static final int MAX_SHORT_NAME_BYTES = 14;
+
+ // See ANSI/CEA-766-C.
+ private static final int RATING_REGION_US_TV = 1;
+ private static final int RATING_REGION_KR_TV = 4;
+
+ // The following values are defined in the live channels app.
+ // See https://developer.android.com/reference/android/media/tv/TvContentRating.html.
+ private static final String RATING_DOMAIN = "com.android.tv";
+ private static final String RATING_REGION_RATING_SYSTEM_US_TV = "US_TV";
+ private static final String RATING_REGION_RATING_SYSTEM_US_MV = "US_MV";
+ private static final String RATING_REGION_RATING_SYSTEM_KR_TV = "KR_TV";
+
+ private static final String[] RATING_REGION_TABLE_US_TV = {
+ "US_TV_Y", "US_TV_Y7", "US_TV_G", "US_TV_PG", "US_TV_14", "US_TV_MA"
+ };
+
+ private static final String[] RATING_REGION_TABLE_US_MV = {
+ "US_MV_G", "US_MV_PG", "US_MV_PG13", "US_MV_R", "US_MV_NC17"
+ };
+
+ private static final String[] RATING_REGION_TABLE_KR_TV = {
+ "KR_TV_ALL", "KR_TV_7", "KR_TV_12", "KR_TV_15", "KR_TV_19"
+ };
+
+ private static final String[] RATING_REGION_TABLE_US_TV_SUBRATING = {
+ "US_TV_D", "US_TV_L", "US_TV_S", "US_TV_V", "US_TV_FV"
+ };
+
+ // According to ANSI-CEA-766-D
+ private static final int VALUE_US_TV_Y = 1;
+ private static final int VALUE_US_TV_Y7 = 2;
+ private static final int VALUE_US_TV_NONE = 1;
+ private static final int VALUE_US_TV_G = 2;
+ private static final int VALUE_US_TV_PG = 3;
+ private static final int VALUE_US_TV_14 = 4;
+ private static final int VALUE_US_TV_MA = 5;
+
+ private static final int DIMENSION_US_TV_RATING = 0;
+ private static final int DIMENSION_US_TV_D = 1;
+ private static final int DIMENSION_US_TV_L = 2;
+ private static final int DIMENSION_US_TV_S = 3;
+ private static final int DIMENSION_US_TV_V = 4;
+ private static final int DIMENSION_US_TV_Y = 5;
+ private static final int DIMENSION_US_TV_FV = 6;
+ private static final int DIMENSION_US_MV_RATING = 7;
+
+ private static final int VALUE_US_MV_G = 2;
+ private static final int VALUE_US_MV_PG = 3;
+ private static final int VALUE_US_MV_PG13 = 4;
+ private static final int VALUE_US_MV_R = 5;
+ private static final int VALUE_US_MV_NC17 = 6;
+ private static final int VALUE_US_MV_X = 7;
+
+ private static final String STRING_US_TV_Y = "US_TV_Y";
+ private static final String STRING_US_TV_Y7 = "US_TV_Y7";
+ private static final String STRING_US_TV_FV = "US_TV_FV";
+
+ /*
+ * The following CRC table is from the code generated by the following command.
+ * $ python pycrc.py --model crc-32-mpeg --algorithm table-driven --generate c
+ * To see the details of pycrc, visit http://www.tty1.net/pycrc/index_en.html
+ */
+ public static final int[] CRC_TABLE = {
+ 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9,
+ 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005,
+ 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61,
+ 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd,
+ 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9,
+ 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75,
+ 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011,
+ 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd,
+ 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039,
+ 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5,
+ 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81,
+ 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d,
+ 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49,
+ 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95,
+ 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1,
+ 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d,
+ 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae,
+ 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072,
+ 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16,
+ 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca,
+ 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde,
+ 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02,
+ 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066,
+ 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba,
+ 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e,
+ 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692,
+ 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6,
+ 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a,
+ 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e,
+ 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2,
+ 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686,
+ 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a,
+ 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637,
+ 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb,
+ 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f,
+ 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53,
+ 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47,
+ 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b,
+ 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff,
+ 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623,
+ 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7,
+ 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b,
+ 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f,
+ 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3,
+ 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7,
+ 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b,
+ 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f,
+ 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3,
+ 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640,
+ 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c,
+ 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8,
+ 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24,
+ 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30,
+ 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec,
+ 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088,
+ 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654,
+ 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0,
+ 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c,
+ 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18,
+ 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4,
+ 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0,
+ 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c,
+ 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668,
+ 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4
+ };
+
+ // A table which maps ATSC genres to TIF genres.
+ // See ATSC/65 Table 6.20.
+ private static final String[] CANONICAL_GENRES_TABLE = {
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ Genres.EDUCATION,
+ Genres.ENTERTAINMENT,
+ Genres.MOVIES,
+ Genres.NEWS,
+ Genres.LIFE_STYLE,
+ Genres.SPORTS,
+ null,
+ Genres.MOVIES,
+ null,
+ Genres.FAMILY_KIDS,
+ Genres.DRAMA,
+ null,
+ Genres.ENTERTAINMENT,
+ Genres.SPORTS,
+ Genres.SPORTS,
+ null,
+ null,
+ Genres.MUSIC,
+ Genres.EDUCATION,
+ null,
+ Genres.COMEDY,
+ null,
+ Genres.MUSIC,
+ null,
+ null,
+ Genres.MOVIES,
+ Genres.ENTERTAINMENT,
+ Genres.NEWS,
+ Genres.DRAMA,
+ Genres.EDUCATION,
+ Genres.MOVIES,
+ Genres.SPORTS,
+ Genres.MOVIES,
+ null,
+ Genres.LIFE_STYLE,
+ Genres.ARTS,
+ Genres.LIFE_STYLE,
+ Genres.SPORTS,
+ null,
+ null,
+ Genres.GAMING,
+ Genres.LIFE_STYLE,
+ Genres.SPORTS,
+ null,
+ Genres.LIFE_STYLE,
+ Genres.EDUCATION,
+ Genres.EDUCATION,
+ Genres.LIFE_STYLE,
+ Genres.SPORTS,
+ Genres.LIFE_STYLE,
+ Genres.MOVIES,
+ Genres.NEWS,
+ null,
+ null,
+ null,
+ Genres.EDUCATION,
+ null,
+ null,
+ null,
+ Genres.EDUCATION,
+ null,
+ null,
+ null,
+ Genres.DRAMA,
+ Genres.MUSIC,
+ Genres.MOVIES,
+ null,
+ Genres.ANIMAL_WILDLIFE,
+ null,
+ null,
+ Genres.PREMIER,
+ null,
+ null,
+ null,
+ null,
+ Genres.SPORTS,
+ Genres.ARTS,
+ null,
+ null,
+ null,
+ Genres.MOVIES,
+ Genres.TECH_SCIENCE,
+ Genres.DRAMA,
+ null,
+ Genres.SHOPPING,
+ Genres.DRAMA,
+ null,
+ Genres.MOVIES,
+ Genres.ENTERTAINMENT,
+ Genres.TECH_SCIENCE,
+ Genres.SPORTS,
+ Genres.TRAVEL,
+ Genres.ENTERTAINMENT,
+ Genres.ARTS,
+ Genres.NEWS,
+ null,
+ Genres.ARTS,
+ Genres.SPORTS,
+ Genres.SPORTS,
+ Genres.NEWS,
+ Genres.SPORTS,
+ Genres.SPORTS,
+ Genres.SPORTS,
+ Genres.FAMILY_KIDS,
+ Genres.FAMILY_KIDS,
+ Genres.MOVIES,
+ null,
+ Genres.TECH_SCIENCE,
+ Genres.MUSIC,
+ null,
+ Genres.SPORTS,
+ Genres.FAMILY_KIDS,
+ Genres.NEWS,
+ Genres.SPORTS,
+ Genres.NEWS,
+ Genres.SPORTS,
+ Genres.ANIMAL_WILDLIFE,
+ null,
+ Genres.MUSIC,
+ Genres.NEWS,
+ Genres.SPORTS,
+ null,
+ Genres.NEWS,
+ Genres.NEWS,
+ Genres.NEWS,
+ Genres.NEWS,
+ Genres.SPORTS,
+ Genres.MOVIES,
+ Genres.ARTS,
+ Genres.ANIMAL_WILDLIFE,
+ Genres.MUSIC,
+ Genres.MUSIC,
+ Genres.MOVIES,
+ Genres.EDUCATION,
+ Genres.DRAMA,
+ Genres.SPORTS,
+ Genres.SPORTS,
+ Genres.SPORTS,
+ Genres.SPORTS,
+ null,
+ Genres.SPORTS,
+ Genres.SPORTS,
+ };
+
+ // A table which contains ATSC categorical genre code assignments.
+ // See ATSC/65 Table 6.20.
+ private static final String[] BROADCAST_GENRES_TABLE =
+ new String[] {
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ "Education",
+ "Entertainment",
+ "Movie",
+ "News",
+ "Religious",
+ "Sports",
+ "Other",
+ "Action",
+ "Advertisement",
+ "Animated",
+ "Anthology",
+ "Automobile",
+ "Awards",
+ "Baseball",
+ "Basketball",
+ "Bulletin",
+ "Business",
+ "Classical",
+ "College",
+ "Combat",
+ "Comedy",
+ "Commentary",
+ "Concert",
+ "Consumer",
+ "Contemporary",
+ "Crime",
+ "Dance",
+ "Documentary",
+ "Drama",
+ "Elementary",
+ "Erotica",
+ "Exercise",
+ "Fantasy",
+ "Farm",
+ "Fashion",
+ "Fiction",
+ "Food",
+ "Football",
+ "Foreign",
+ "Fund Raiser",
+ "Game/Quiz",
+ "Garden",
+ "Golf",
+ "Government",
+ "Health",
+ "High School",
+ "History",
+ "Hobby",
+ "Hockey",
+ "Home",
+ "Horror",
+ "Information",
+ "Instruction",
+ "International",
+ "Interview",
+ "Language",
+ "Legal",
+ "Live",
+ "Local",
+ "Math",
+ "Medical",
+ "Meeting",
+ "Military",
+ "Miniseries",
+ "Music",
+ "Mystery",
+ "National",
+ "Nature",
+ "Police",
+ "Politics",
+ "Premier",
+ "Prerecorded",
+ "Product",
+ "Professional",
+ "Public",
+ "Racing",
+ "Reading",
+ "Repair",
+ "Repeat",
+ "Review",
+ "Romance",
+ "Science",
+ "Series",
+ "Service",
+ "Shopping",
+ "Soap Opera",
+ "Special",
+ "Suspense",
+ "Talk",
+ "Technical",
+ "Tennis",
+ "Travel",
+ "Variety",
+ "Video",
+ "Weather",
+ "Western",
+ "Art",
+ "Auto Racing",
+ "Aviation",
+ "Biography",
+ "Boating",
+ "Bowling",
+ "Boxing",
+ "Cartoon",
+ "Children",
+ "Classic Film",
+ "Community",
+ "Computers",
+ "Country Music",
+ "Court",
+ "Extreme Sports",
+ "Family",
+ "Financial",
+ "Gymnastics",
+ "Headlines",
+ "Horse Racing",
+ "Hunting/Fishing/Outdoors",
+ "Independent",
+ "Jazz",
+ "Magazine",
+ "Motorcycle Racing",
+ "Music/Film/Books",
+ "News-International",
+ "News-Local",
+ "News-National",
+ "News-Regional",
+ "Olympics",
+ "Original",
+ "Performing Arts",
+ "Pets/Animals",
+ "Pop",
+ "Rock & Roll",
+ "Sci-Fi",
+ "Self Improvement",
+ "Sitcom",
+ "Skating",
+ "Skiing",
+ "Soccer",
+ "Track/Field",
+ "True",
+ "Volleyball",
+ "Wrestling",
+ };
+
+ // Audio language code map from ISO 639-2/B to 639-2/T, in order to show correct audio language.
+ private static final HashMap<String, String> ISO_LANGUAGE_CODE_MAP;
+
+ static {
+ ISO_LANGUAGE_CODE_MAP = new HashMap<>();
+ ISO_LANGUAGE_CODE_MAP.put("alb", "sqi");
+ ISO_LANGUAGE_CODE_MAP.put("arm", "hye");
+ ISO_LANGUAGE_CODE_MAP.put("baq", "eus");
+ ISO_LANGUAGE_CODE_MAP.put("bur", "mya");
+ ISO_LANGUAGE_CODE_MAP.put("chi", "zho");
+ ISO_LANGUAGE_CODE_MAP.put("cze", "ces");
+ ISO_LANGUAGE_CODE_MAP.put("dut", "nld");
+ ISO_LANGUAGE_CODE_MAP.put("fre", "fra");
+ ISO_LANGUAGE_CODE_MAP.put("geo", "kat");
+ ISO_LANGUAGE_CODE_MAP.put("ger", "deu");
+ ISO_LANGUAGE_CODE_MAP.put("gre", "ell");
+ ISO_LANGUAGE_CODE_MAP.put("ice", "isl");
+ ISO_LANGUAGE_CODE_MAP.put("mac", "mkd");
+ ISO_LANGUAGE_CODE_MAP.put("mao", "mri");
+ ISO_LANGUAGE_CODE_MAP.put("may", "msa");
+ ISO_LANGUAGE_CODE_MAP.put("per", "fas");
+ ISO_LANGUAGE_CODE_MAP.put("rum", "ron");
+ ISO_LANGUAGE_CODE_MAP.put("slo", "slk");
+ ISO_LANGUAGE_CODE_MAP.put("tib", "bod");
+ ISO_LANGUAGE_CODE_MAP.put("wel", "cym");
+ ISO_LANGUAGE_CODE_MAP.put("esl", "spa"); // Special entry for channel 9-1 KQED in bay area.
+ }
+
+ @Nullable
+ private static final Charset SCSU_CHARSET =
+ Charset.isSupported("SCSU") ? Charset.forName("SCSU") : null;
+
+ // Containers to store the last version numbers of the PSIP sections.
+ private final HashMap<PsipSection, Integer> mSectionVersionMap = new HashMap<>();
+ private final SparseArray<List<EttItem>> mParsedEttItems = new SparseArray<>();
+
+ public interface OutputListener {
+ void onPatParsed(List<PatItem> items);
+
+ void onPmtParsed(int programNumber, List<PmtItem> items);
+
+ void onMgtParsed(List<MgtItem> items);
+
+ void onVctParsed(List<VctItem> items, int sectionNumber, int lastSectionNumber);
+
+ void onEitParsed(int sourceId, List<EitItem> items);
+
+ void onEttParsed(int sourceId, List<EttItem> descriptions);
+
+ void onSdtParsed(List<SdtItem> items);
+ }
+
+ private final OutputListener mListener;
+
+ public SectionParser(OutputListener listener) {
+ mListener = listener;
+ }
+
+ public void parseSections(ByteArrayBuffer data) {
+ int pos = 0;
+ while (pos + 3 <= data.length()) {
+ if ((data.byteAt(pos) & 0xff) == 0xff) {
+ // Clear stuffing bytes according to H222.0 section 2.4.4.
+ data.setLength(0);
+ break;
+ }
+ int sectionLength =
+ (((data.byteAt(pos + 1) & 0x0f) << 8) | (data.byteAt(pos + 2) & 0xff)) + 3;
+ if (pos + sectionLength > data.length()) {
+ break;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "parseSections 0x" + Integer.toHexString(data.byteAt(pos) & 0xff));
+ }
+ parseSection(Arrays.copyOfRange(data.buffer(), pos, pos + sectionLength));
+ pos += sectionLength;
+ }
+ if (mListener != null) {
+ for (int i = 0; i < mParsedEttItems.size(); ++i) {
+ int sourceId = mParsedEttItems.keyAt(i);
+ List<EttItem> descriptions = mParsedEttItems.valueAt(i);
+ mListener.onEttParsed(sourceId, descriptions);
+ }
+ }
+ mParsedEttItems.clear();
+ }
+
+ public void resetVersionNumbers() {
+ mSectionVersionMap.clear();
+ }
+
+ private void parseSection(byte[] data) {
+ if (!checkSanity(data)) {
+ Log.d(TAG, "Bad CRC!");
+ return;
+ }
+ PsipSection section = PsipSection.create(data);
+ if (section == null) {
+ return;
+ }
+
+ // The currentNextIndicator indicates that the section sent is currently applicable.
+ if (!section.getCurrentNextIndicator()) {
+ return;
+ }
+ int versionNumber = (data[5] & 0x3e) >> 1;
+ Integer oldVersionNumber = mSectionVersionMap.get(section);
+
+ // The versionNumber shall be incremented when a change in the information carried within
+ // the section occurs.
+ if (oldVersionNumber != null && versionNumber == oldVersionNumber) {
+ return;
+ }
+ boolean result = false;
+ switch (data[0]) {
+ case TABLE_ID_PAT:
+ result = parsePAT(data);
+ break;
+ case TABLE_ID_PMT:
+ result = parsePMT(data);
+ break;
+ case TABLE_ID_MGT:
+ result = parseMGT(data);
+ break;
+ case TABLE_ID_TVCT:
+ case TABLE_ID_CVCT:
+ result = parseVCT(data);
+ break;
+ case TABLE_ID_EIT:
+ result = parseEIT(data);
+ break;
+ case TABLE_ID_ETT:
+ result = parseETT(data);
+ break;
+ case TABLE_ID_SDT:
+ result = parseSDT(data);
+ break;
+ case TABLE_ID_DVB_ACTUAL_P_F_EIT:
+ case TABLE_ID_DVB_ACTUAL_SCHEDULE_EIT:
+ result = parseDVBEIT(data);
+ break;
+ default:
+ break;
+ }
+ if (result) {
+ mSectionVersionMap.put(section, versionNumber);
+ }
+ }
+
+ private boolean parsePAT(byte[] data) {
+ if (DEBUG) {
+ Log.d(TAG, "PAT is discovered.");
+ }
+ int pos = 8;
+
+ List<PatItem> results = new ArrayList<>();
+ for (; pos < data.length - 4; pos = pos + 4) {
+ if (pos > data.length - 4 - 4) {
+ Log.e(TAG, "Broken PAT.");
+ return false;
+ }
+ int programNo = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff);
+ int pmtPid = ((data[pos + 2] & 0x1f) << 8) | (data[pos + 3] & 0xff);
+ results.add(new PatItem(programNo, pmtPid));
+ }
+ if (mListener != null) {
+ mListener.onPatParsed(results);
+ }
+ return true;
+ }
+
+ private boolean parsePMT(byte[] data) {
+ int table_id_ext = ((data[3] & 0xff) << 8) | (data[4] & 0xff);
+ if (DEBUG) {
+ Log.d(TAG, "PMT is discovered. programNo = " + table_id_ext);
+ }
+ if (data.length <= 11) {
+ Log.e(TAG, "Broken PMT.");
+ return false;
+ }
+ int pcrPid = (data[8] & 0x1f) << 8 | data[9];
+ int programInfoLen = (data[10] & 0x0f) << 8 | data[11];
+ int pos = 12;
+ List<TsDescriptor> descriptors = parseDescriptors(data, pos, pos + programInfoLen);
+ pos += programInfoLen;
+ if (DEBUG) {
+ Log.d(TAG, "PMT descriptors size: " + descriptors.size());
+ }
+ List<PmtItem> results = new ArrayList<>();
+ for (; pos < data.length - 4; ) {
+ if (pos < 0) {
+ Log.e(TAG, "Broken PMT.");
+ return false;
+ }
+ int streamType = data[pos] & 0xff;
+ int esPid = (data[pos + 1] & 0x1f) << 8 | (data[pos + 2] & 0xff);
+ int esInfoLen = (data[pos + 3] & 0xf) << 8 | (data[pos + 4] & 0xff);
+ if (data.length < pos + esInfoLen + 5) {
+ Log.e(TAG, "Broken PMT.");
+ return false;
+ }
+ descriptors = parseDescriptors(data, pos + 5, pos + 5 + esInfoLen);
+ List<AtscAudioTrack> audioTracks = generateAudioTracks(descriptors);
+ List<AtscCaptionTrack> captionTracks = generateCaptionTracks(descriptors);
+ PmtItem pmtItem = new PmtItem(streamType, esPid, audioTracks, captionTracks);
+ if (DEBUG) {
+ Log.d(TAG, "PMT " + pmtItem + " descriptors size: " + descriptors.size());
+ }
+ results.add(pmtItem);
+ pos = pos + esInfoLen + 5;
+ }
+ results.add(new PmtItem(PmtItem.ES_PID_PCR, pcrPid, null, null));
+ if (mListener != null) {
+ mListener.onPmtParsed(table_id_ext, results);
+ }
+ return true;
+ }
+
+ private boolean parseMGT(byte[] data) {
+ // For details of the structure for MGT, see ATSC A/65 Table 6.2.
+ if (DEBUG) {
+ Log.d(TAG, "MGT is discovered.");
+ }
+ if (data.length <= 10) {
+ Log.e(TAG, "Broken MGT.");
+ return false;
+ }
+ int tablesDefined = ((data[9] & 0xff) << 8) | (data[10] & 0xff);
+ int pos = 11;
+ List<MgtItem> results = new ArrayList<>();
+ for (int i = 0; i < tablesDefined; ++i) {
+ if (data.length <= pos + 10) {
+ Log.e(TAG, "Broken MGT.");
+ return false;
+ }
+ int tableType = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff);
+ int tableTypePid = ((data[pos + 2] & 0x1f) << 8) | (data[pos + 3] & 0xff);
+ int descriptorsLength = ((data[pos + 9] & 0x0f) << 8) | (data[pos + 10] & 0xff);
+ pos += 11 + descriptorsLength;
+ results.add(new MgtItem(tableType, tableTypePid));
+ }
+ // Skip the remaining descriptor part which we don't use.
+
+ if (mListener != null) {
+ mListener.onMgtParsed(results);
+ }
+ return true;
+ }
+
+ private boolean parseVCT(byte[] data) {
+ // For details of the structure for VCT, see ATSC A/65 Table 6.4 and 6.8.
+ if (DEBUG) {
+ Log.d(TAG, "VCT is discovered.");
+ }
+ if (data.length <= 9) {
+ Log.e(TAG, "Broken VCT.");
+ return false;
+ }
+ int numChannelsInSection = (data[9] & 0xff);
+ int sectionNumber = (data[6] & 0xff);
+ int lastSectionNumber = (data[7] & 0xff);
+ if (sectionNumber > lastSectionNumber) {
+ // According to section 6.3.1 of the spec ATSC A/65,
+ // last section number is the largest section number.
+ Log.w(
+ TAG,
+ "Invalid VCT. Section Number "
+ + sectionNumber
+ + " > Last Section Number "
+ + lastSectionNumber);
+ return false;
+ }
+ int pos = 10;
+ List<VctItem> results = new ArrayList<>();
+ for (int i = 0; i < numChannelsInSection; ++i) {
+ if (data.length <= pos + 31) {
+ Log.e(TAG, "Broken VCT.");
+ return false;
+ }
+ String shortName = "";
+ int shortNameSize = getShortNameSize(data, pos);
+ try {
+ shortName =
+ new String(Arrays.copyOfRange(data, pos, pos + shortNameSize), "UTF-16");
+ } catch (UnsupportedEncodingException e) {
+ Log.e(TAG, "Broken VCT.", e);
+ return false;
+ }
+ if ((data[pos + 14] & 0xf0) != 0xf0) {
+ Log.e(TAG, "Broken VCT.");
+ return false;
+ }
+ int majorNumber = ((data[pos + 14] & 0x0f) << 6) | ((data[pos + 15] & 0xff) >> 2);
+ int minorNumber = ((data[pos + 15] & 0x03) << 8) | (data[pos + 16] & 0xff);
+ if ((majorNumber & 0x3f0) == 0x3f0) {
+ // If the six MSBs are 111111, these indicate that there is only one-part channel
+ // number. To see details, refer A/65 Section 6.3.2.
+ majorNumber = ((majorNumber & 0xf) << 10) + minorNumber;
+ minorNumber = 0;
+ }
+ int channelTsid = ((data[pos + 22] & 0xff) << 8) | (data[pos + 23] & 0xff);
+ int programNumber = ((data[pos + 24] & 0xff) << 8) | (data[pos + 25] & 0xff);
+ boolean accessControlled = (data[pos + 26] & 0x20) != 0;
+ boolean hidden = (data[pos + 26] & 0x10) != 0;
+ int serviceType = (data[pos + 27] & 0x3f);
+ int sourceId = ((data[pos + 28] & 0xff) << 8) | (data[pos + 29] & 0xff);
+ int descriptorsPos = pos + 32;
+ int descriptorsLength = ((data[pos + 30] & 0x03) << 8) | (data[pos + 31] & 0xff);
+ pos += 32 + descriptorsLength;
+ if (data.length < pos) {
+ Log.e(TAG, "Broken VCT.");
+ return false;
+ }
+ List<TsDescriptor> descriptors =
+ parseDescriptors(data, descriptorsPos, descriptorsPos + descriptorsLength);
+ String longName = null;
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof ExtendedChannelNameDescriptor) {
+ ExtendedChannelNameDescriptor extendedChannelNameDescriptor =
+ (ExtendedChannelNameDescriptor) descriptor;
+ longName = extendedChannelNameDescriptor.getLongChannelName();
+ break;
+ }
+ }
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "Found channel [%s] %s - serviceType: %d tsid: 0x%x program: %d "
+ + "channel: %d-%d encrypted: %b hidden: %b, descriptors: %d",
+ shortName,
+ longName,
+ serviceType,
+ channelTsid,
+ programNumber,
+ majorNumber,
+ minorNumber,
+ accessControlled,
+ hidden,
+ descriptors.size()));
+ }
+ if (!accessControlled
+ && !hidden
+ && (serviceType == Channel.AtscServiceType.SERVICE_TYPE_ATSC_AUDIO
+ || serviceType
+ == Channel.AtscServiceType.SERVICE_TYPE_ATSC_DIGITAL_TELEVISION
+ || serviceType
+ == Channel.AtscServiceType
+ .SERVICE_TYPE_UNASSOCIATED_SMALL_SCREEN_SERVICE)) {
+ // Hide hidden, encrypted, or unsupported ATSC service type channels
+ results.add(
+ new VctItem(
+ shortName,
+ longName,
+ serviceType,
+ channelTsid,
+ programNumber,
+ majorNumber,
+ minorNumber,
+ sourceId));
+ }
+ }
+ // Skip the remaining descriptor part which we don't use.
+
+ if (mListener != null) {
+ mListener.onVctParsed(results, sectionNumber, lastSectionNumber);
+ }
+ return true;
+ }
+
+ private boolean parseEIT(byte[] data) {
+ // For details of the structure for EIT, see ATSC A/65 Table 6.11.
+ if (DEBUG) {
+ Log.d(TAG, "EIT is discovered.");
+ }
+ if (data.length <= 9) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ int sourceId = ((data[3] & 0xff) << 8) | (data[4] & 0xff);
+ int numEventsInSection = (data[9] & 0xff);
+
+ int pos = 10;
+ List<EitItem> results = new ArrayList<>();
+ for (int i = 0; i < numEventsInSection; ++i) {
+ if (data.length <= pos + 9) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ if ((data[pos] & 0xc0) != 0xc0) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ int eventId = ((data[pos] & 0x3f) << 8) + (data[pos + 1] & 0xff);
+ long startTime =
+ ((data[pos + 2] & (long) 0xff) << 24)
+ | ((data[pos + 3] & 0xff) << 16)
+ | ((data[pos + 4] & 0xff) << 8)
+ | (data[pos + 5] & 0xff);
+ int lengthInSecond =
+ ((data[pos + 6] & 0x0f) << 16)
+ | ((data[pos + 7] & 0xff) << 8)
+ | (data[pos + 8] & 0xff);
+ int titleLength = (data[pos + 9] & 0xff);
+ if (data.length <= pos + 10 + titleLength + 1) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ String titleText = "";
+ if (titleLength > 0) {
+ titleText = extractText(data, pos + 10);
+ }
+ if ((data[pos + 10 + titleLength] & 0xf0) != 0xf0) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ int descriptorsLength =
+ ((data[pos + 10 + titleLength] & 0x0f) << 8)
+ | (data[pos + 10 + titleLength + 1] & 0xff);
+ int descriptorsPos = pos + 10 + titleLength + 2;
+ if (data.length < descriptorsPos + descriptorsLength) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ List<TsDescriptor> descriptors =
+ parseDescriptors(data, descriptorsPos, descriptorsPos + descriptorsLength);
+ if (DEBUG) {
+ Log.d(TAG, String.format("EIT descriptors size: %d", descriptors.size()));
+ }
+ String contentRating = generateContentRating(descriptors);
+ String broadcastGenre = generateBroadcastGenre(descriptors);
+ String canonicalGenre = generateCanonicalGenre(descriptors);
+ List<AtscAudioTrack> audioTracks = generateAudioTracks(descriptors);
+ List<AtscCaptionTrack> captionTracks = generateCaptionTracks(descriptors);
+ pos += 10 + titleLength + 2 + descriptorsLength;
+ results.add(
+ new EitItem(
+ EitItem.INVALID_PROGRAM_ID,
+ eventId,
+ titleText,
+ startTime,
+ lengthInSecond,
+ contentRating,
+ audioTracks,
+ captionTracks,
+ broadcastGenre,
+ canonicalGenre,
+ null));
+ }
+ if (mListener != null) {
+ mListener.onEitParsed(sourceId, results);
+ }
+ return true;
+ }
+
+ private boolean parseETT(byte[] data) {
+ // For details of the structure for ETT, see ATSC A/65 Table 6.13.
+ if (DEBUG) {
+ Log.d(TAG, "ETT is discovered.");
+ }
+ if (data.length <= 12) {
+ Log.e(TAG, "Broken ETT.");
+ return false;
+ }
+ int sourceId = ((data[9] & 0xff) << 8) | (data[10] & 0xff);
+ int eventId = (((data[11] & 0xff) << 8) | (data[12] & 0xff)) >> 2;
+ String text = extractText(data, 13);
+ List<EttItem> ettItems = mParsedEttItems.get(sourceId);
+ if (ettItems == null) {
+ ettItems = new ArrayList<>();
+ mParsedEttItems.put(sourceId, ettItems);
+ }
+ ettItems.add(new EttItem(eventId, text));
+ return true;
+ }
+
+ private boolean parseSDT(byte[] data) {
+ // For details of the structure for SDT, see DVB Document A038 Table 5.
+ if (DEBUG) {
+ Log.d(TAG, "SDT id discovered");
+ }
+ if (data.length <= 11) {
+ Log.e(TAG, "Broken SDT.");
+ return false;
+ }
+ if ((data[1] & 0x80) >> 7 != 1) {
+ Log.e(TAG, "Broken SDT, section syntax indicator error.");
+ return false;
+ }
+ int sectionLength = ((data[1] & 0x0f) << 8) | (data[2] & 0xff);
+ int transportStreamId = ((data[3] & 0xff) << 8) | (data[4] & 0xff);
+ int originalNetworkId = ((data[8] & 0xff) << 8) | (data[9] & 0xff);
+ int pos = 11;
+ if (sectionLength + 3 > data.length) {
+ Log.e(TAG, "Broken SDT.");
+ }
+ List<SdtItem> sdtItems = new ArrayList<>();
+ while (pos + 9 < data.length) {
+ int serviceId = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff);
+ int descriptorsLength = ((data[pos + 3] & 0x0f) << 8) | (data[pos + 4] & 0xff);
+ pos += 5;
+ List<TsDescriptor> descriptors = parseDescriptors(data, pos, pos + descriptorsLength);
+ List<ServiceDescriptor> serviceDescriptors = generateServiceDescriptors(descriptors);
+ String serviceName = "";
+ String serviceProviderName = "";
+ int serviceType = 0;
+ for (ServiceDescriptor serviceDescriptor : serviceDescriptors) {
+ serviceName = serviceDescriptor.getServiceName();
+ serviceProviderName = serviceDescriptor.getServiceProviderName();
+ serviceType = serviceDescriptor.getServiceType();
+ }
+ if (serviceDescriptors.size() > 0) {
+ sdtItems.add(
+ new SdtItem(
+ serviceName,
+ serviceProviderName,
+ serviceType,
+ serviceId,
+ originalNetworkId));
+ }
+ pos += descriptorsLength;
+ }
+ if (mListener != null) {
+ mListener.onSdtParsed(sdtItems);
+ }
+ return true;
+ }
+
+ private boolean parseDVBEIT(byte[] data) {
+ // For details of the structure for DVB ETT, see DVB Document A038 Table 7.
+ if (DEBUG) {
+ Log.d(TAG, "DVB EIT is discovered.");
+ }
+ if (data.length < 18) {
+ Log.e(TAG, "Broken DVB EIT.");
+ return false;
+ }
+ int sectionLength = ((data[1] & 0x0f) << 8) | (data[2] & 0xff);
+ int sourceId = ((data[3] & 0xff) << 8) | (data[4] & 0xff);
+ int transportStreamId = ((data[8] & 0xff) << 8) | (data[9] & 0xff);
+ int originalNetworkId = ((data[10] & 0xff) << 8) | (data[11] & 0xff);
+
+ int pos = 14;
+ List<EitItem> results = new ArrayList<>();
+ while (pos + 12 < data.length) {
+ int eventId = ((data[pos] & 0xff) << 8) + (data[pos + 1] & 0xff);
+ float modifiedJulianDate = ((data[pos + 2] & 0xff) << 8) | (data[pos + 3] & 0xff);
+ int startYear = (int) ((modifiedJulianDate - 15078.2f) / 365.25f);
+ int mjdMonth =
+ (int)
+ ((modifiedJulianDate - 14956.1f - (int) (startYear * 365.25f))
+ / 30.6001f);
+ int startDay =
+ (int) modifiedJulianDate
+ - 14956
+ - (int) (startYear * 365.25f)
+ - (int) (mjdMonth * 30.6001f);
+ int startMonth = mjdMonth - 1;
+ if (mjdMonth == 14 || mjdMonth == 15) {
+ startYear += 1;
+ startMonth -= 12;
+ }
+ int startHour = ((data[pos + 4] & 0xf0) >> 4) * 10 + (data[pos + 4] & 0x0f);
+ int startMinute = ((data[pos + 5] & 0xf0) >> 4) * 10 + (data[pos + 5] & 0x0f);
+ int startSecond = ((data[pos + 6] & 0xf0) >> 4) * 10 + (data[pos + 6] & 0x0f);
+ Calendar calendar = Calendar.getInstance();
+ startYear += 1900;
+ calendar.set(startYear, startMonth, startDay, startHour, startMinute, startSecond);
+ long startTime =
+ ConvertUtils.convertUnixEpochToGPSTime(calendar.getTimeInMillis() / 1000);
+ int durationInSecond =
+ (((data[pos + 7] & 0xf0) >> 4) * 10 + (data[pos + 7] & 0x0f)) * 3600
+ + (((data[pos + 8] & 0xf0) >> 4) * 10 + (data[pos + 8] & 0x0f)) * 60
+ + (((data[pos + 9] & 0xf0) >> 4) * 10 + (data[pos + 9] & 0x0f));
+ int descriptorsLength = ((data[pos + 10] & 0x0f) << 8) | (data[pos + 10 + 1] & 0xff);
+ int descriptorsPos = pos + 10 + 2;
+ if (data.length < descriptorsPos + descriptorsLength) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ List<TsDescriptor> descriptors =
+ parseDescriptors(data, descriptorsPos, descriptorsPos + descriptorsLength);
+ if (DEBUG) {
+ Log.d(TAG, String.format("DVB EIT descriptors size: %d", descriptors.size()));
+ }
+ // TODO: Add logic to generating content rating for dvb. See DVB document 6.2.28 for
+ // details. Content rating here will be null
+ String contentRating = generateContentRating(descriptors);
+ // TODO: Add logic for generating genre for dvb. See DVB document 6.2.9 for details.
+ // Genre here will be null here.
+ String broadcastGenre = generateBroadcastGenre(descriptors);
+ String canonicalGenre = generateCanonicalGenre(descriptors);
+ String titleText = generateShortEventName(descriptors);
+ List<AtscAudioTrack> audioTracks = generateAudioTracks(descriptors);
+ List<AtscCaptionTrack> captionTracks = generateCaptionTracks(descriptors);
+ pos += 12 + descriptorsLength;
+ results.add(
+ new EitItem(
+ EitItem.INVALID_PROGRAM_ID,
+ eventId,
+ titleText,
+ startTime,
+ durationInSecond,
+ contentRating,
+ audioTracks,
+ captionTracks,
+ broadcastGenre,
+ canonicalGenre,
+ null));
+ }
+ if (mListener != null) {
+ mListener.onEitParsed(sourceId, results);
+ }
+ return true;
+ }
+
+ private static List<AtscAudioTrack> generateAudioTracks(List<TsDescriptor> descriptors) {
+ // The list of audio tracks sent is located at both AC3 Audio descriptor and ISO 639
+ // Language descriptor.
+ List<AtscAudioTrack> ac3Tracks = new ArrayList<>();
+ List<AtscAudioTrack> iso639LanguageTracks = new ArrayList<>();
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof Ac3AudioDescriptor) {
+ Ac3AudioDescriptor audioDescriptor = (Ac3AudioDescriptor) descriptor;
+ AtscAudioTrack audioTrack = new AtscAudioTrack();
+ if (audioDescriptor.getLanguage() != null) {
+ audioTrack.language = audioDescriptor.getLanguage();
+ }
+ if (audioTrack.language == null) {
+ audioTrack.language = "";
+ }
+ audioTrack.audioType = AtscAudioTrack.AudioType.AUDIOTYPE_UNDEFINED;
+ audioTrack.channelCount = audioDescriptor.getNumChannels();
+ audioTrack.sampleRate = audioDescriptor.getSampleRate();
+ ac3Tracks.add(audioTrack);
+ }
+ }
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof Iso639LanguageDescriptor) {
+ Iso639LanguageDescriptor iso639LanguageDescriptor =
+ (Iso639LanguageDescriptor) descriptor;
+ iso639LanguageTracks.addAll(iso639LanguageDescriptor.getAudioTracks());
+ }
+ }
+
+ // An AC3 audio stream descriptor only has a audio channel count and a audio sample rate
+ // while a ISO 639 Language descriptor only has a audio type, which describes a main use
+ // case of its audio track.
+ // Some channels contain only AC3 audio stream descriptors with valid language values.
+ // Other channels contain both an AC3 audio stream descriptor and a ISO 639 Language
+ // descriptor per audio track, and those AC3 audio stream descriptors often have a null
+ // value of language field.
+ // Combines two descriptors into one in order to gather more audio track specific
+ // information as much as possible.
+ List<AtscAudioTrack> tracks = new ArrayList<>();
+ if (!ac3Tracks.isEmpty()
+ && !iso639LanguageTracks.isEmpty()
+ && ac3Tracks.size() != iso639LanguageTracks.size()) {
+ // This shouldn't be happen. In here, it handles two cases. The first case is that the
+ // only one type of descriptors arrives. The second case is that the two types of
+ // descriptors have the same number of tracks.
+ Log.e(TAG, "AC3 audio stream descriptors size != ISO 639 Language descriptors size");
+ return tracks;
+ }
+ int size = Math.max(ac3Tracks.size(), iso639LanguageTracks.size());
+ for (int i = 0; i < size; ++i) {
+ AtscAudioTrack audioTrack = null;
+ if (i < ac3Tracks.size()) {
+ audioTrack = ac3Tracks.get(i);
+ }
+ if (i < iso639LanguageTracks.size()) {
+ if (audioTrack == null) {
+ audioTrack = iso639LanguageTracks.get(i);
+ } else {
+ AtscAudioTrack iso639LanguageTrack = iso639LanguageTracks.get(i);
+ if (audioTrack.language == null || TextUtils.equals(audioTrack.language, "")) {
+ audioTrack.language = iso639LanguageTrack.language;
+ }
+ audioTrack.audioType = iso639LanguageTrack.audioType;
+ }
+ }
+ String language = ISO_LANGUAGE_CODE_MAP.get(audioTrack.language);
+ if (language != null) {
+ audioTrack.language = language;
+ }
+ tracks.add(audioTrack);
+ }
+ return tracks;
+ }
+
+ private static List<AtscCaptionTrack> generateCaptionTracks(List<TsDescriptor> descriptors) {
+ List<AtscCaptionTrack> services = new ArrayList<>();
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof CaptionServiceDescriptor) {
+ CaptionServiceDescriptor captionServiceDescriptor =
+ (CaptionServiceDescriptor) descriptor;
+ services.addAll(captionServiceDescriptor.getCaptionTracks());
+ }
+ }
+ return services;
+ }
+
+ @VisibleForTesting
+ static String generateContentRating(List<TsDescriptor> descriptors) {
+ Set<String> contentRatings = new ArraySet<>();
+ List<RatingRegion> usRatingRegions = getRatingRegions(descriptors, RATING_REGION_US_TV);
+ List<RatingRegion> krRatingRegions = getRatingRegions(descriptors, RATING_REGION_KR_TV);
+ for (RatingRegion region : usRatingRegions) {
+ String contentRating = getUsRating(region);
+ if (contentRating != null) {
+ contentRatings.add(contentRating);
+ }
+ }
+ for (RatingRegion region : krRatingRegions) {
+ String contentRating = getKrRating(region);
+ if (contentRating != null) {
+ contentRatings.add(contentRating);
+ }
+ }
+ return TextUtils.join(",", contentRatings);
+ }
+
+ /**
+ * Gets a list of {@link RatingRegion} in the specific region.
+ *
+ * @param descriptors {@link TsDescriptor} list which may contains rating information
+ * @param region the specific region
+ * @return a list of {@link RatingRegion} in the specific region
+ */
+ private static List<RatingRegion> getRatingRegions(List<TsDescriptor> descriptors, int region) {
+ List<RatingRegion> ratingRegions = new ArrayList<>();
+ for (TsDescriptor descriptor : descriptors) {
+ if (!(descriptor instanceof ContentAdvisoryDescriptor)) {
+ continue;
+ }
+ ContentAdvisoryDescriptor contentAdvisoryDescriptor =
+ (ContentAdvisoryDescriptor) descriptor;
+ for (RatingRegion ratingRegion : contentAdvisoryDescriptor.getRatingRegions()) {
+ if (ratingRegion.getName() == region) {
+ ratingRegions.add(ratingRegion);
+ }
+ }
+ }
+ return ratingRegions;
+ }
+
+ /**
+ * Gets US content rating and subratings (if any).
+ *
+ * @param ratingRegion a {@link RatingRegion} instance which may contain rating information.
+ * @return A string representing the US content rating and subratings. The format of the string
+ * is defined in {@link TvContentRating}. null, if no such a string exists.
+ */
+ private static String getUsRating(RatingRegion ratingRegion) {
+ if (ratingRegion.getName() != RATING_REGION_US_TV) {
+ return null;
+ }
+ List<RegionalRating> regionalRatings = ratingRegion.getRegionalRatings();
+ String rating = null;
+ int ratingIndex = VALUE_US_TV_NONE;
+ List<String> subratings = new ArrayList<>();
+ for (RegionalRating index : regionalRatings) {
+ // See Table 3 of ANSI-CEA-766-D
+ int dimension = index.getDimension();
+ int value = index.getRating();
+ switch (dimension) {
+ // According to Table 6.27 of ATSC A65,
+ // the dimensions shall be in increasing order.
+ // Therefore, rating and ratingIndex are assigned before any corresponding
+ // subrating.
+ case DIMENSION_US_TV_RATING:
+ if (value >= VALUE_US_TV_G && value < RATING_REGION_TABLE_US_TV.length) {
+ rating = RATING_REGION_TABLE_US_TV[value];
+ ratingIndex = value;
+ }
+ break;
+ case DIMENSION_US_TV_D:
+ if (value == 1
+ && (ratingIndex == VALUE_US_TV_PG || ratingIndex == VALUE_US_TV_14)) {
+ // US_TV_D is applicable to US_TV_PG and US_TV_14
+ subratings.add(RATING_REGION_TABLE_US_TV_SUBRATING[dimension - 1]);
+ }
+ break;
+ case DIMENSION_US_TV_L:
+ case DIMENSION_US_TV_S:
+ case DIMENSION_US_TV_V:
+ if (value == 1
+ && ratingIndex >= VALUE_US_TV_PG
+ && ratingIndex <= VALUE_US_TV_MA) {
+ // US_TV_L, US_TV_S, and US_TV_V are applicable to
+ // US_TV_PG, US_TV_14 and US_TV_MA
+ subratings.add(RATING_REGION_TABLE_US_TV_SUBRATING[dimension - 1]);
+ }
+ break;
+ case DIMENSION_US_TV_Y:
+ if (rating == null) {
+ if (value == VALUE_US_TV_Y) {
+ rating = STRING_US_TV_Y;
+ } else if (value == VALUE_US_TV_Y7) {
+ rating = STRING_US_TV_Y7;
+ }
+ }
+ break;
+ case DIMENSION_US_TV_FV:
+ if (STRING_US_TV_Y7.equals(rating) && value == 1) {
+ // US_TV_FV is applicable to US_TV_Y7
+ subratings.add(STRING_US_TV_FV);
+ }
+ break;
+ case DIMENSION_US_MV_RATING:
+ if (value >= VALUE_US_MV_G && value <= VALUE_US_MV_X) {
+ if (value == VALUE_US_MV_X) {
+ // US_MV_X was replaced by US_MV_NC17 in 1990,
+ // and it's not supported by TvContentRating
+ value = VALUE_US_MV_NC17;
+ }
+ if (rating != null) {
+ // According to Table 3 of ANSI-CEA-766-D,
+ // DIMENSION_US_TV_RATING and DIMENSION_US_MV_RATING shall not be
+ // present in the same descriptor.
+ Log.w(
+ TAG,
+ "DIMENSION_US_TV_RATING and DIMENSION_US_MV_RATING are "
+ + "present in the same descriptor");
+ } else {
+ return TvContentRating.createRating(
+ RATING_DOMAIN,
+ RATING_REGION_RATING_SYSTEM_US_MV,
+ RATING_REGION_TABLE_US_MV[value - 2])
+ .flattenToString();
+ }
+ }
+ break;
+
+ default:
+ break;
+ }
+ }
+ if (rating == null) {
+ return null;
+ }
+
+ String[] subratingArray = subratings.toArray(new String[subratings.size()]);
+ return TvContentRating.createRating(
+ RATING_DOMAIN, RATING_REGION_RATING_SYSTEM_US_TV, rating, subratingArray)
+ .flattenToString();
+ }
+
+ /**
+ * Gets KR(South Korea) content rating.
+ *
+ * @param ratingRegion a {@link RatingRegion} instance which may contain rating information.
+ * @return A string representing the KR content rating. The format of the string is defined in
+ * {@link TvContentRating}. null, if no such a string exists.
+ */
+ private static String getKrRating(RatingRegion ratingRegion) {
+ if (ratingRegion.getName() != RATING_REGION_KR_TV) {
+ return null;
+ }
+ List<RegionalRating> regionalRatings = ratingRegion.getRegionalRatings();
+ String rating = null;
+ for (RegionalRating index : regionalRatings) {
+ if (index.getDimension() == 0
+ && index.getRating() >= 0
+ && index.getRating() < RATING_REGION_TABLE_KR_TV.length) {
+ rating = RATING_REGION_TABLE_KR_TV[index.getRating()];
+ break;
+ }
+ }
+ if (rating == null) {
+ return null;
+ }
+ return TvContentRating.createRating(
+ RATING_DOMAIN, RATING_REGION_RATING_SYSTEM_KR_TV, rating)
+ .flattenToString();
+ }
+
+ private static String generateBroadcastGenre(List<TsDescriptor> descriptors) {
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof GenreDescriptor) {
+ GenreDescriptor genreDescriptor = (GenreDescriptor) descriptor;
+ return TextUtils.join(",", genreDescriptor.getBroadcastGenres());
+ }
+ }
+ return null;
+ }
+
+ private static String generateCanonicalGenre(List<TsDescriptor> descriptors) {
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof GenreDescriptor) {
+ GenreDescriptor genreDescriptor = (GenreDescriptor) descriptor;
+ return Genres.encode(genreDescriptor.getCanonicalGenres());
+ }
+ }
+ return null;
+ }
+
+ private static List<ServiceDescriptor> generateServiceDescriptors(
+ List<TsDescriptor> descriptors) {
+ List<ServiceDescriptor> serviceDescriptors = new ArrayList<>();
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof ServiceDescriptor) {
+ ServiceDescriptor serviceDescriptor = (ServiceDescriptor) descriptor;
+ serviceDescriptors.add(serviceDescriptor);
+ }
+ }
+ return serviceDescriptors;
+ }
+
+ private static String generateShortEventName(List<TsDescriptor> descriptors) {
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof ShortEventDescriptor) {
+ ShortEventDescriptor shortEventDescriptor = (ShortEventDescriptor) descriptor;
+ return shortEventDescriptor.getEventName();
+ }
+ }
+ return "";
+ }
+
+ private static List<TsDescriptor> parseDescriptors(byte[] data, int offset, int limit) {
+ // For details of the structure for descriptors, see ATSC A/65 Section 6.9.
+ List<TsDescriptor> descriptors = new ArrayList<>();
+ if (data.length < limit) {
+ return descriptors;
+ }
+ int pos = offset;
+ while (pos + 1 < limit) {
+ int tag = data[pos] & 0xff;
+ int length = data[pos + 1] & 0xff;
+ if (length <= 0) {
+ break;
+ }
+ if (limit < pos + length + 2) {
+ break;
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format("Descriptor tag: %02x", tag));
+ }
+ TsDescriptor descriptor = null;
+ switch (tag) {
+ case DESCRIPTOR_TAG_CONTENT_ADVISORY:
+ descriptor = parseContentAdvisory(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_CAPTION_SERVICE:
+ descriptor = parseCaptionService(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME:
+ descriptor = parseLongChannelName(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_GENRE:
+ descriptor = parseGenre(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_AC3_AUDIO_STREAM:
+ descriptor = parseAc3AudioStream(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_ISO639LANGUAGE:
+ descriptor = parseIso639Language(data, pos, pos + length + 2);
+ break;
+
+ case DVB_DESCRIPTOR_TAG_SERVICE:
+ descriptor = parseDvbService(data, pos, pos + length + 2);
+ break;
+
+ case DVB_DESCRIPTOR_TAG_SHORT_EVENT:
+ descriptor = parseDvbShortEvent(data, pos, pos + length + 2);
+ break;
+
+ case DVB_DESCRIPTOR_TAG_CONTENT:
+ descriptor = parseDvbContent(data, pos, pos + length + 2);
+ break;
+
+ case DVB_DESCRIPTOR_TAG_PARENTAL_RATING:
+ descriptor = parseDvbParentalRating(data, pos, pos + length + 2);
+ break;
+
+ default:
+ }
+ if (descriptor != null) {
+ if (DEBUG) {
+ Log.d(TAG, "Descriptor parsed: " + descriptor);
+ }
+ descriptors.add(descriptor);
+ }
+ pos += length + 2;
+ }
+ return descriptors;
+ }
+
+ private static Iso639LanguageDescriptor parseIso639Language(byte[] data, int pos, int limit) {
+ // For the details of the structure of ISO 639 language descriptor,
+ // see ISO13818-1 second edition Section 2.6.18.
+ pos += 2;
+ List<AtscAudioTrack> audioTracks = new ArrayList<>();
+ while (pos + 4 <= limit) {
+ if (limit <= pos + 3) {
+ Log.e(TAG, "Broken Iso639Language.");
+ return null;
+ }
+ String language = new String(data, pos, 3);
+ int audioType = data[pos + 3] & 0xff;
+ AtscAudioTrack audioTrack = new AtscAudioTrack();
+ audioTrack.language = language;
+ audioTrack.audioType = audioType;
+ audioTracks.add(audioTrack);
+ pos += 4;
+ }
+ return new Iso639LanguageDescriptor(audioTracks);
+ }
+
+ private static CaptionServiceDescriptor parseCaptionService(byte[] data, int pos, int limit) {
+ // For the details of the structure of caption service descriptor,
+ // see ATSC A/65 Section 6.9.2.
+ if (limit <= pos + 2) {
+ Log.e(TAG, "Broken CaptionServiceDescriptor.");
+ return null;
+ }
+ List<AtscCaptionTrack> services = new ArrayList<>();
+ pos += 2;
+ int numberServices = data[pos] & 0x1f;
+ ++pos;
+ if (limit < pos + numberServices * 6) {
+ Log.e(TAG, "Broken CaptionServiceDescriptor.");
+ return null;
+ }
+ for (int i = 0; i < numberServices; ++i) {
+ String language = new String(Arrays.copyOfRange(data, pos, pos + 3));
+ pos += 3;
+ boolean ccType = (data[pos] & 0x80) != 0;
+ if (!ccType) {
+ pos += 3;
+ continue;
+ }
+ int captionServiceNumber = data[pos] & 0x3f;
+ ++pos;
+ boolean easyReader = (data[pos] & 0x80) != 0;
+ boolean wideAspectRatio = (data[pos] & 0x40) != 0;
+ byte[] reserved = new byte[2];
+ reserved[0] = (byte) (data[pos] << 2);
+ reserved[0] |= (byte) ((data[pos + 1] & 0xc0) >>> 6);
+ reserved[1] = (byte) ((data[pos + 1] & 0x3f) << 2);
+ pos += 2;
+ AtscCaptionTrack captionTrack = new AtscCaptionTrack();
+ captionTrack.language = language;
+ captionTrack.serviceNumber = captionServiceNumber;
+ captionTrack.easyReader = easyReader;
+ captionTrack.wideAspectRatio = wideAspectRatio;
+ services.add(captionTrack);
+ }
+ return new CaptionServiceDescriptor(services);
+ }
+
+ private static ContentAdvisoryDescriptor parseContentAdvisory(byte[] data, int pos, int limit) {
+ // For details of the structure for content advisory descriptor, see A/65 Table 6.27.
+ if (limit <= pos + 2) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ int count = data[pos + 2] & 0x3f;
+ pos += 3;
+ List<RatingRegion> ratingRegions = new ArrayList<>();
+ for (int i = 0; i < count; ++i) {
+ if (limit <= pos + 1) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ List<RegionalRating> indices = new ArrayList<>();
+ int ratingRegion = data[pos] & 0xff;
+ int dimensionCount = data[pos + 1] & 0xff;
+ pos += 2;
+ int previousDimension = -1;
+ for (int j = 0; j < dimensionCount; ++j) {
+ if (limit <= pos + 1) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ int dimensionIndex = data[pos] & 0xff;
+ int ratingValue = data[pos + 1] & 0x0f;
+ if (dimensionIndex <= previousDimension) {
+ // According to Table 6.27 of ATSC A65,
+ // the indices shall be in increasing order.
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ previousDimension = dimensionIndex;
+ pos += 2;
+ indices.add(new RegionalRating(dimensionIndex, ratingValue));
+ }
+ if (limit <= pos) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ int ratingDescriptionLength = data[pos] & 0xff;
+ ++pos;
+ if (limit < pos + ratingDescriptionLength) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ String ratingDescription = extractText(data, pos);
+ pos += ratingDescriptionLength;
+ ratingRegions.add(new RatingRegion(ratingRegion, ratingDescription, indices));
+ }
+ return new ContentAdvisoryDescriptor(ratingRegions);
+ }
+
+ private static ExtendedChannelNameDescriptor parseLongChannelName(
+ byte[] data, int pos, int limit) {
+ if (limit <= pos + 2) {
+ Log.e(TAG, "Broken ExtendedChannelName.");
+ return null;
+ }
+ pos += 2;
+ String text = extractText(data, pos);
+ if (text == null) {
+ Log.e(TAG, "Broken ExtendedChannelName.");
+ return null;
+ }
+ return new ExtendedChannelNameDescriptor(text);
+ }
+
+ private static GenreDescriptor parseGenre(byte[] data, int pos, int limit) {
+ pos += 2;
+ int attributeCount = data[pos] & 0x1f;
+ if (limit <= pos + attributeCount) {
+ Log.e(TAG, "Broken Genre.");
+ return null;
+ }
+ HashSet<String> broadcastGenreSet = new HashSet<>();
+ HashSet<String> canonicalGenreSet = new HashSet<>();
+ for (int i = 0; i < attributeCount; ++i) {
+ ++pos;
+ int genreCode = data[pos] & 0xff;
+ if (genreCode < BROADCAST_GENRES_TABLE.length) {
+ String broadcastGenre = BROADCAST_GENRES_TABLE[genreCode];
+ if (broadcastGenre != null && !broadcastGenreSet.contains(broadcastGenre)) {
+ broadcastGenreSet.add(broadcastGenre);
+ }
+ }
+ if (genreCode < CANONICAL_GENRES_TABLE.length) {
+ String canonicalGenre = CANONICAL_GENRES_TABLE[genreCode];
+ if (canonicalGenre != null && !canonicalGenreSet.contains(canonicalGenre)) {
+ canonicalGenreSet.add(canonicalGenre);
+ }
+ }
+ }
+ return new GenreDescriptor(
+ broadcastGenreSet.toArray(new String[broadcastGenreSet.size()]),
+ canonicalGenreSet.toArray(new String[canonicalGenreSet.size()]));
+ }
+
+ private static TsDescriptor parseAc3AudioStream(byte[] data, int pos, int limit) {
+ // For details of the AC3 audio stream descriptor, see A/52 Table A4.1.
+ if (limit <= pos + 5) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor.");
+ return null;
+ }
+ pos += 2;
+ byte sampleRateCode = (byte) ((data[pos] & 0xe0) >> 5);
+ byte bsid = (byte) (data[pos] & 0x1f);
+ ++pos;
+ byte bitRateCode = (byte) ((data[pos] & 0xfc) >> 2);
+ byte surroundMode = (byte) (data[pos] & 0x03);
+ ++pos;
+ byte bsmod = (byte) ((data[pos] & 0xe0) >> 5);
+ int numChannels = (data[pos] & 0x1e) >> 1;
+ boolean fullSvc = (data[pos] & 0x01) != 0;
+ ++pos;
+ byte langCod = data[pos];
+ byte langCod2 = 0;
+ if (numChannels == 0) {
+ if (limit <= pos) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor.");
+ return null;
+ }
+ ++pos;
+ langCod2 = data[pos];
+ }
+ if (limit <= pos + 1) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor.");
+ return null;
+ }
+ byte mainId = 0;
+ byte priority = 0;
+ byte asvcflags = 0;
+ ++pos;
+ if (bsmod < 2) {
+ mainId = (byte) ((data[pos] & 0xe0) >> 5);
+ priority = (byte) ((data[pos] & 0x18) >> 3);
+ if ((data[pos] & 0x07) != 0x07) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor reserved failed");
+ return null;
+ }
+ } else {
+ asvcflags = data[pos];
+ }
+
+ // See A/52B Table A3.6 num_channels.
+ int numEncodedChannels;
+ switch (numChannels) {
+ case 1:
+ case 8:
+ numEncodedChannels = 1;
+ break;
+ case 2:
+ case 9:
+ numEncodedChannels = 2;
+ break;
+ case 3:
+ case 4:
+ case 10:
+ numEncodedChannels = 3;
+ break;
+ case 5:
+ case 6:
+ case 11:
+ numEncodedChannels = 4;
+ break;
+ case 7:
+ case 12:
+ numEncodedChannels = 5;
+ break;
+ case 13:
+ numEncodedChannels = 6;
+ break;
+ default:
+ numEncodedChannels = 0;
+ break;
+ }
+
+ if (limit <= pos + 1) {
+ Log.w(TAG, "Missing text and language fields on AC3 audio stream descriptor.");
+ return new Ac3AudioDescriptor(
+ sampleRateCode,
+ bsid,
+ bitRateCode,
+ surroundMode,
+ bsmod,
+ numEncodedChannels,
+ fullSvc,
+ langCod,
+ langCod2,
+ mainId,
+ priority,
+ asvcflags,
+ null,
+ null,
+ null);
+ }
+ ++pos;
+ int textLen = (data[pos] & 0xfe) >> 1;
+ boolean textCode = (data[pos] & 0x01) != 0;
+ ++pos;
+ String text = "";
+ if (textLen > 0) {
+ if (limit < pos + textLen) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor");
+ return null;
+ }
+ if (textCode) {
+ text = new String(data, pos, textLen);
+ } else {
+ text = new String(data, pos, textLen, Charset.forName("UTF-16"));
+ }
+ pos += textLen;
+ }
+ String language = null;
+ String language2 = null;
+ if (pos < limit) {
+ // Many AC3 audio stream descriptors skip the language fields.
+ boolean languageFlag1 = (data[pos] & 0x80) != 0;
+ boolean languageFlag2 = (data[pos] & 0x40) != 0;
+ if ((data[pos] & 0x3f) != 0x3f) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor");
+ return null;
+ }
+ if (pos + (languageFlag1 ? 3 : 0) + (languageFlag2 ? 3 : 0) > limit) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor");
+ return null;
+ }
+ ++pos;
+ if (languageFlag1) {
+ language = new String(data, pos, 3);
+ pos += 3;
+ }
+ if (languageFlag2) {
+ language2 = new String(data, pos, 3);
+ }
+ }
+
+ return new Ac3AudioDescriptor(
+ sampleRateCode,
+ bsid,
+ bitRateCode,
+ surroundMode,
+ bsmod,
+ numEncodedChannels,
+ fullSvc,
+ langCod,
+ langCod2,
+ mainId,
+ priority,
+ asvcflags,
+ text,
+ language,
+ language2);
+ }
+
+ private static TsDescriptor parseDvbService(byte[] data, int pos, int limit) {
+ // For details of DVB service descriptors, see DVB Document A038 Table 86.
+ if (limit < pos + 5) {
+ Log.e(TAG, "Broken service descriptor.");
+ return null;
+ }
+ pos += 2;
+ int serviceType = data[pos] & 0xff;
+ pos++;
+ int serviceProviderNameLength = data[pos] & 0xff;
+ pos++;
+ String serviceProviderName = extractTextFromDvb(data, pos, serviceProviderNameLength);
+ pos += serviceProviderNameLength;
+ int serviceNameLength = data[pos] & 0xff;
+ pos++;
+ String serviceName = extractTextFromDvb(data, pos, serviceNameLength);
+ return new ServiceDescriptor(serviceType, serviceProviderName, serviceName);
+ }
+
+ private static TsDescriptor parseDvbShortEvent(byte[] data, int pos, int limit) {
+ // For details of DVB service descriptors, see DVB Document A038 Table 91.
+ if (limit < pos + 7) {
+ Log.e(TAG, "Broken short event descriptor.");
+ return null;
+ }
+ pos += 2;
+ String language = new String(data, pos, 3);
+ int eventNameLength = data[pos + 3] & 0xff;
+ pos += 4;
+ if (pos + eventNameLength > limit) {
+ Log.e(TAG, "Broken short event descriptor.");
+ return null;
+ }
+ String eventName = new String(data, pos, eventNameLength);
+ pos += eventNameLength;
+ int textLength = data[pos] & 0xff;
+ if (pos + textLength > limit) {
+ Log.e(TAG, "Broken short event descriptor.");
+ return null;
+ }
+ pos++;
+ String text = new String(data, pos, textLength);
+ return new ShortEventDescriptor(language, eventName, text);
+ }
+
+ private static TsDescriptor parseDvbContent(byte[] data, int pos, int limit) {
+ // TODO: According to DVB Document A038 Table 27 to add a parser for content descriptor to
+ // get content genre.
+ return null;
+ }
+
+ private static TsDescriptor parseDvbParentalRating(byte[] data, int pos, int limit) {
+ // For details of DVB service descriptors, see DVB Document A038 Table 81.
+ HashMap<String, Integer> ratings = new HashMap<>();
+ pos += 2;
+ while (pos + 4 <= limit) {
+ String countryCode = new String(data, pos, 3);
+ int rating = data[pos + 3] & 0xff;
+ pos += 4;
+ if (rating > 15) {
+ // Rating > 15 means that the ratings is defined by broadcaster.
+ continue;
+ }
+ ratings.put(countryCode, rating + 3);
+ }
+ return new ParentalRatingDescriptor(ratings);
+ }
+
+ private static int getShortNameSize(byte[] data, int offset) {
+ for (int i = 0; i < MAX_SHORT_NAME_BYTES; i += 2) {
+ if (data[offset + i] == 0 && data[offset + i + 1] == 0) {
+ return i;
+ }
+ }
+ return MAX_SHORT_NAME_BYTES;
+ }
+
+ private static String extractText(byte[] data, int pos) {
+ if (data.length < pos) {
+ return null;
+ }
+ int numStrings = data[pos] & 0xff;
+ pos++;
+ for (int i = 0; i < numStrings; ++i) {
+ if (data.length <= pos + 3) {
+ Log.e(TAG, "Broken text.");
+ return null;
+ }
+ int numSegments = data[pos + 3] & 0xff;
+ pos += 4;
+ for (int j = 0; j < numSegments; ++j) {
+ if (data.length <= pos + 2) {
+ Log.e(TAG, "Broken text.");
+ return null;
+ }
+ int compressionType = data[pos] & 0xff;
+ int mode = data[pos + 1] & 0xff;
+ int numBytes = data[pos + 2] & 0xff;
+ if (data.length < pos + 3 + numBytes) {
+ Log.e(TAG, "Broken text.");
+ return null;
+ }
+ if (compressionType == COMPRESSION_TYPE_NO_COMPRESSION) {
+ switch (mode) {
+ case MODE_SELECTED_UNICODE_RANGE_1:
+ return new String(data, pos + 3, numBytes, StandardCharsets.ISO_8859_1);
+ case MODE_SCSU:
+ if (SCSU_CHARSET != null) {
+ return new String(data, pos + 3, numBytes, SCSU_CHARSET);
+ } else {
+ Log.w(TAG, "SCSU not supported");
+ return null;
+ }
+ case MODE_UTF16:
+ return new String(data, pos + 3, numBytes, StandardCharsets.UTF_16);
+ default:
+ Log.w(TAG, "Unsupported text mode " + mode);
+ return null;
+ }
+ }
+ pos += 3 + numBytes;
+ }
+ }
+ return null;
+ }
+
+ private static String extractTextFromDvb(byte[] data, int pos, int length) {
+ // For details of DVB character set selection, see DVB Document A038 Annex A.
+ if (data.length < pos + length) {
+ return null;
+ }
+ try {
+ String charsetPrefix = "ISO-8859-";
+ switch (data[0]) {
+ case 0x01:
+ case 0x02:
+ case 0x03:
+ case 0x04:
+ case 0x05:
+ case 0x06:
+ case 0x07:
+ case 0x09:
+ case 0x0A:
+ case 0x0B:
+ String charset = charsetPrefix + String.valueOf(data[0] & 0xff + 4);
+ return new String(data, pos, length, charset);
+ case 0x10:
+ if (length < 3) {
+ Log.e(TAG, "Broken DVB text");
+ return null;
+ }
+ int codeTable = data[pos + 2] & 0xff;
+ if (data[pos + 1] == 0 && codeTable > 0 && codeTable < 15) {
+ return new String(
+ data, pos, length, charsetPrefix + String.valueOf(codeTable));
+ } else {
+ return new String(data, pos, length, "ISO-8859-1");
+ }
+ case 0x11:
+ case 0x14:
+ case 0x15:
+ return new String(data, pos, length, "UTF-16BE");
+ case 0x12:
+ return new String(data, pos, length, "EUC-KR");
+ case 0x13:
+ return new String(data, pos, length, "GB2312");
+ default:
+ return new String(data, pos, length, "ISO-8859-1");
+ }
+ } catch (UnsupportedEncodingException e) {
+ Log.e(TAG, "Unsupported text format.", e);
+ }
+ return new String(data, pos, length);
+ }
+
+ private static boolean checkSanity(byte[] data) {
+ if (data.length <= 1) {
+ return false;
+ }
+ boolean hasCRC = (data[1] & 0x80) != 0; // section_syntax_indicator
+ if (hasCRC) {
+ int crc = 0xffffffff;
+ for (byte b : data) {
+ int index = ((crc >> 24) ^ (b & 0xff)) & 0xff;
+ crc = CRC_TABLE[index] ^ (crc << 8);
+ }
+ if (crc != 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/ts/TsParser.java b/tuner/src/com/android/tv/tuner/ts/TsParser.java
new file mode 100644
index 00000000..2307c22a
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/ts/TsParser.java
@@ -0,0 +1,543 @@
+/*
+ * 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.ts;
+
+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.EttItem;
+import com.android.tv.tuner.data.PsipData.MgtItem;
+import com.android.tv.tuner.data.PsipData.SdtItem;
+import com.android.tv.tuner.data.PsipData.VctItem;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.ts.SectionParser.OutputListener;
+import com.android.tv.tuner.util.ByteArrayBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+
+/** Parses MPEG-2 TS packets. */
+public class TsParser {
+ private static final String TAG = "TsParser";
+ private static final boolean DEBUG = false;
+
+ public static final int ATSC_SI_BASE_PID = 0x1ffb;
+ public static final int PAT_PID = 0x0000;
+ public static final int DVB_SDT_PID = 0x0011;
+ public static final int DVB_EIT_PID = 0x0012;
+ private static final int TS_PACKET_START_CODE = 0x47;
+ private static final int TS_PACKET_TEI_MASK = 0x80;
+ private static final int TS_PACKET_SIZE = 188;
+
+ /*
+ * Using a SparseArray removes the need to auto box the int key for mStreamMap
+ * in feedTdPacket which is called 100 times a second. This greatly reduces the
+ * number of objects created and the frequency of garbage collection.
+ * Other maps might be suitable for a SparseArray, but the performance
+ * trade offs must be considered carefully.
+ * mStreamMap is the only one called at such a high rate.
+ */
+ private final SparseArray<Stream> mStreamMap = new SparseArray<>();
+ private final Map<Integer, VctItem> mSourceIdToVctItemMap = new HashMap<>();
+ private final Map<Integer, String> mSourceIdToVctItemDescriptionMap = new HashMap<>();
+ private final Map<Integer, VctItem> mProgramNumberToVctItemMap = new HashMap<>();
+ private final Map<Integer, List<PmtItem>> mProgramNumberToPMTMap = new HashMap<>();
+ private final Map<Integer, List<EitItem>> mSourceIdToEitMap = new HashMap<>();
+ private final Map<Integer, SdtItem> mProgramNumberToSdtItemMap = new HashMap<>();
+ private final Map<EventSourceEntry, List<EitItem>> mEitMap = new HashMap<>();
+ private final Map<EventSourceEntry, List<EttItem>> mETTMap = new HashMap<>();
+ private final TreeSet<Integer> mEITPids = new TreeSet<>();
+ private final TreeSet<Integer> mETTPids = new TreeSet<>();
+ private final SparseBooleanArray mProgramNumberHandledStatus = new SparseBooleanArray();
+ private final SparseBooleanArray mVctItemHandledStatus = new SparseBooleanArray();
+ private final TsOutputListener mListener;
+ private final boolean mIsDvbSignal;
+
+ private int mVctItemCount;
+ private int mHandledVctItemCount;
+ private int mVctSectionParsedCount;
+ private boolean[] mVctSectionParsed;
+
+ public interface TsOutputListener {
+ void onPatDetected(List<PatItem> items);
+
+ void onEitPidDetected(int pid);
+
+ void onVctItemParsed(VctItem channel, List<PmtItem> pmtItems);
+
+ void onEitItemParsed(VctItem channel, List<EitItem> items);
+
+ void onEttPidDetected(int pid);
+
+ void onAllVctItemsParsed();
+
+ void onSdtItemParsed(SdtItem channel, List<PmtItem> pmtItems);
+ }
+
+ private abstract static class Stream {
+ private static final int INVALID_CONTINUITY_COUNTER = -1;
+ private static final int NUM_CONTINUITY_COUNTER = 16;
+
+ protected int mContinuityCounter = INVALID_CONTINUITY_COUNTER;
+ protected final ByteArrayBuffer mPacket = new ByteArrayBuffer(TS_PACKET_SIZE);
+
+ public void feedData(byte[] data, int continuityCounter, boolean startIndicator) {
+ if ((mContinuityCounter + 1) % NUM_CONTINUITY_COUNTER != continuityCounter) {
+ mPacket.setLength(0);
+ }
+ mContinuityCounter = continuityCounter;
+ handleData(data, startIndicator);
+ }
+
+ protected abstract void handleData(byte[] data, boolean startIndicator);
+
+ protected abstract void resetDataVersions();
+ }
+
+ private class SectionStream extends Stream {
+ private final SectionParser mSectionParser;
+ private final int mPid;
+
+ public SectionStream(int pid) {
+ mPid = pid;
+ mSectionParser = new SectionParser(mSectionListener);
+ }
+
+ @Override
+ protected void handleData(byte[] data, boolean startIndicator) {
+ int startPos = 0;
+ if (mPacket.length() == 0) {
+ if (startIndicator) {
+ startPos = (data[0] & 0xff) + 1;
+ } else {
+ // Don't know where the section starts yet. Wait until start indicator is on.
+ return;
+ }
+ } else {
+ if (startIndicator) {
+ startPos = 1;
+ }
+ }
+
+ // When a broken packet is encountered, parsing will stop and return right away.
+ if (startPos >= data.length) {
+ mPacket.setLength(0);
+ return;
+ }
+ mPacket.append(data, startPos, data.length - startPos);
+ mSectionParser.parseSections(mPacket);
+ }
+
+ @Override
+ protected void resetDataVersions() {
+ mSectionParser.resetVersionNumbers();
+ }
+
+ private final OutputListener mSectionListener =
+ new OutputListener() {
+ @Override
+ public void onPatParsed(List<PatItem> items) {
+ for (PatItem i : items) {
+ startListening(i.getPmtPid());
+ }
+ if (mListener != null) {
+ mListener.onPatDetected(items);
+ }
+ }
+
+ @Override
+ public void onPmtParsed(int programNumber, List<PmtItem> items) {
+ mProgramNumberToPMTMap.put(programNumber, items);
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "onPMTParsed, programNo "
+ + programNumber
+ + " handledStatus is "
+ + mProgramNumberHandledStatus.get(
+ programNumber, false));
+ }
+ int statusIndex = mProgramNumberHandledStatus.indexOfKey(programNumber);
+ if (statusIndex < 0) {
+ mProgramNumberHandledStatus.put(programNumber, false);
+ }
+ if (!mProgramNumberHandledStatus.get(programNumber)) {
+ VctItem vctItem = mProgramNumberToVctItemMap.get(programNumber);
+ if (vctItem != null) {
+ // When PMT is parsed later than VCT.
+ mProgramNumberHandledStatus.put(programNumber, true);
+ handleVctItem(vctItem, items);
+ mHandledVctItemCount++;
+ if (mHandledVctItemCount >= mVctItemCount
+ && mVctSectionParsedCount >= mVctSectionParsed.length
+ && mListener != null) {
+ mListener.onAllVctItemsParsed();
+ }
+ }
+ SdtItem sdtItem = mProgramNumberToSdtItemMap.get(programNumber);
+ if (sdtItem != null) {
+ // When PMT is parsed later than SDT.
+ mProgramNumberHandledStatus.put(programNumber, true);
+ handleSdtItem(sdtItem, items);
+ }
+ }
+ }
+
+ @Override
+ public void onMgtParsed(List<MgtItem> items) {
+ for (MgtItem i : items) {
+ if (mStreamMap.get(i.getTableTypePid()) != null) {
+ continue;
+ }
+ if (i.getTableType() >= MgtItem.TABLE_TYPE_EIT_RANGE_START
+ && i.getTableType() <= MgtItem.TABLE_TYPE_EIT_RANGE_END) {
+ startListening(i.getTableTypePid());
+ mEITPids.add(i.getTableTypePid());
+ if (mListener != null) {
+ mListener.onEitPidDetected(i.getTableTypePid());
+ }
+ } else if (i.getTableType() == MgtItem.TABLE_TYPE_CHANNEL_ETT
+ || (i.getTableType() >= MgtItem.TABLE_TYPE_ETT_RANGE_START
+ && i.getTableType()
+ <= MgtItem.TABLE_TYPE_ETT_RANGE_END)) {
+ startListening(i.getTableTypePid());
+ mETTPids.add(i.getTableTypePid());
+ if (mListener != null) {
+ mListener.onEttPidDetected(i.getTableTypePid());
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onVctParsed(
+ List<VctItem> items, int sectionNumber, int lastSectionNumber) {
+ if (mVctSectionParsed == null) {
+ mVctSectionParsed = new boolean[lastSectionNumber + 1];
+ } else if (mVctSectionParsed[sectionNumber]) {
+ // The current section was handled before.
+ if (DEBUG) {
+ Log.d(TAG, "Duplicate VCT section found.");
+ }
+ return;
+ }
+ mVctSectionParsed[sectionNumber] = true;
+ mVctSectionParsedCount++;
+ mVctItemCount += items.size();
+ for (VctItem i : items) {
+ if (DEBUG) Log.d(TAG, "onVCTParsed " + i);
+ if (i.getSourceId() != 0) {
+ mSourceIdToVctItemMap.put(i.getSourceId(), i);
+ i.setDescription(
+ mSourceIdToVctItemDescriptionMap.get(i.getSourceId()));
+ }
+ int programNumber = i.getProgramNumber();
+ mProgramNumberToVctItemMap.put(programNumber, i);
+ List<PmtItem> pmtList = mProgramNumberToPMTMap.get(programNumber);
+ if (pmtList != null) {
+ mProgramNumberHandledStatus.put(programNumber, true);
+ handleVctItem(i, pmtList);
+ mHandledVctItemCount++;
+ if (mHandledVctItemCount >= mVctItemCount
+ && mVctSectionParsedCount >= mVctSectionParsed.length
+ && mListener != null) {
+ mListener.onAllVctItemsParsed();
+ }
+ } else {
+ mProgramNumberHandledStatus.put(programNumber, false);
+ Log.i(
+ TAG,
+ "onVCTParsed, but PMT for programNo "
+ + programNumber
+ + " is not found yet.");
+ }
+ }
+ }
+
+ @Override
+ public void onEitParsed(int sourceId, List<EitItem> items) {
+ if (DEBUG) Log.d(TAG, "onEITParsed " + sourceId);
+ EventSourceEntry entry = new EventSourceEntry(mPid, sourceId);
+ mEitMap.put(entry, items);
+ handleEvents(sourceId);
+ }
+
+ @Override
+ public void onEttParsed(int sourceId, List<EttItem> descriptions) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ String.format(
+ "onETTParsed sourceId: %d, descriptions.size(): %d",
+ sourceId, descriptions.size()));
+ }
+ for (EttItem item : descriptions) {
+ if (item.eventId == 0) {
+ // Channel description
+ mSourceIdToVctItemDescriptionMap.put(sourceId, item.text);
+ VctItem vctItem = mSourceIdToVctItemMap.get(sourceId);
+ if (vctItem != null) {
+ vctItem.setDescription(item.text);
+ List<PmtItem> pmtItems =
+ mProgramNumberToPMTMap.get(vctItem.getProgramNumber());
+ if (pmtItems != null) {
+ handleVctItem(vctItem, pmtItems);
+ }
+ }
+ }
+ }
+
+ // Event Information description
+ EventSourceEntry entry = new EventSourceEntry(mPid, sourceId);
+ mETTMap.put(entry, descriptions);
+ handleEvents(sourceId);
+ }
+
+ @Override
+ public void onSdtParsed(List<SdtItem> sdtItems) {
+ for (SdtItem sdtItem : sdtItems) {
+ if (DEBUG) Log.d(TAG, "onSdtParsed " + sdtItem);
+ int programNumber = sdtItem.getServiceId();
+ mProgramNumberToSdtItemMap.put(programNumber, sdtItem);
+ List<PmtItem> pmtList = mProgramNumberToPMTMap.get(programNumber);
+ if (pmtList != null) {
+ mProgramNumberHandledStatus.put(programNumber, true);
+ handleSdtItem(sdtItem, pmtList);
+ } else {
+ mProgramNumberHandledStatus.put(programNumber, false);
+ Log.i(
+ TAG,
+ "onSdtParsed, but PMT for programNo "
+ + programNumber
+ + " is not found yet.");
+ }
+ }
+ }
+ };
+ }
+
+ private static class EventSourceEntry {
+ public final int pid;
+ public final int sourceId;
+
+ public EventSourceEntry(int pid, int sourceId) {
+ this.pid = pid;
+ this.sourceId = sourceId;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = 17;
+ result = 31 * result + pid;
+ result = 31 * result + sourceId;
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof EventSourceEntry) {
+ EventSourceEntry another = (EventSourceEntry) obj;
+ return pid == another.pid && sourceId == another.sourceId;
+ }
+ return false;
+ }
+ }
+
+ private void handleVctItem(VctItem channel, List<PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "handleVctItem " + channel);
+ }
+ if (mListener != null) {
+ mListener.onVctItemParsed(channel, pmtItems);
+ }
+ int sourceId = channel.getSourceId();
+ int statusIndex = mVctItemHandledStatus.indexOfKey(sourceId);
+ if (statusIndex < 0) {
+ mVctItemHandledStatus.put(sourceId, false);
+ return;
+ }
+ if (!mVctItemHandledStatus.valueAt(statusIndex)) {
+ List<EitItem> eitItems = mSourceIdToEitMap.get(sourceId);
+ if (eitItems != null) {
+ // When VCT is parsed later than EIT.
+ mVctItemHandledStatus.put(sourceId, true);
+ handleEitItems(channel, eitItems);
+ }
+ }
+ }
+
+ private void handleEitItems(VctItem channel, List<EitItem> items) {
+ if (mListener != null) {
+ mListener.onEitItemParsed(channel, items);
+ }
+ }
+
+ private void handleSdtItem(SdtItem channel, List<PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "handleSdtItem " + channel);
+ }
+ if (mListener != null) {
+ mListener.onSdtItemParsed(channel, pmtItems);
+ }
+ }
+
+ private void handleEvents(int sourceId) {
+ Map<Integer, EitItem> itemSet = new HashMap<>();
+ for (int pid : mEITPids) {
+ List<EitItem> eitItems = mEitMap.get(new EventSourceEntry(pid, sourceId));
+ if (eitItems != null) {
+ for (EitItem item : eitItems) {
+ item.setDescription(null);
+ itemSet.put(item.getEventId(), item);
+ }
+ }
+ }
+ for (int pid : mETTPids) {
+ List<EttItem> ettItems = mETTMap.get(new EventSourceEntry(pid, sourceId));
+ if (ettItems != null) {
+ for (EttItem ettItem : ettItems) {
+ if (ettItem.eventId != 0) {
+ EitItem item = itemSet.get(ettItem.eventId);
+ if (item != null) {
+ item.setDescription(ettItem.text);
+ }
+ }
+ }
+ }
+ }
+ List<EitItem> items = new ArrayList<>(itemSet.values());
+ mSourceIdToEitMap.put(sourceId, items);
+ VctItem channel = mSourceIdToVctItemMap.get(sourceId);
+ if (channel != null && mProgramNumberHandledStatus.get(channel.getProgramNumber())) {
+ mVctItemHandledStatus.put(sourceId, true);
+ handleEitItems(channel, items);
+ } else {
+ mVctItemHandledStatus.put(sourceId, false);
+ if (!mIsDvbSignal) {
+ // Log only when zapping to non-DVB channels, since there is not VCT in DVB signal.
+ Log.i(TAG, "onEITParsed, but VCT for sourceId " + sourceId + " is not found yet.");
+ }
+ }
+ }
+
+ /**
+ * Creates MPEG-2 TS parser.
+ *
+ * @param listener TsOutputListener
+ */
+ public TsParser(TsOutputListener listener, boolean isDvbSignal) {
+ startListening(PAT_PID);
+ startListening(ATSC_SI_BASE_PID);
+ mIsDvbSignal = isDvbSignal;
+ if (isDvbSignal) {
+ startListening(DVB_EIT_PID);
+ startListening(DVB_SDT_PID);
+ }
+ mListener = listener;
+ }
+
+ private void startListening(int pid) {
+ mStreamMap.put(pid, new SectionStream(pid));
+ }
+
+ private boolean feedTSPacket(byte[] tsData, int pos) {
+ if (tsData.length < pos + TS_PACKET_SIZE) {
+ if (DEBUG) Log.d(TAG, "Data should include a single TS packet.");
+ return false;
+ }
+ if (tsData[pos] != TS_PACKET_START_CODE) {
+ if (DEBUG) Log.d(TAG, "Invalid ts packet.");
+ return false;
+ }
+ if ((tsData[pos + 1] & TS_PACKET_TEI_MASK) != 0) {
+ if (DEBUG) Log.d(TAG, "Erroneous ts packet.");
+ return false;
+ }
+
+ // For details for the structure of TS packet, see H.222.0 Table 2-2.
+ int pid = ((tsData[pos + 1] & 0x1f) << 8) | (tsData[pos + 2] & 0xff);
+ boolean hasAdaptation = (tsData[pos + 3] & 0x20) != 0;
+ boolean hasPayload = (tsData[pos + 3] & 0x10) != 0;
+ boolean payloadStartIndicator = (tsData[pos + 1] & 0x40) != 0;
+ int continuityCounter = tsData[pos + 3] & 0x0f;
+ Stream stream = mStreamMap.get(pid);
+ int payloadPos = pos;
+ payloadPos += hasAdaptation ? 5 + (tsData[pos + 4] & 0xff) : 4;
+ if (!hasPayload || stream == null) {
+ // We are not interested in this packet.
+ return false;
+ }
+ if (payloadPos >= pos + TS_PACKET_SIZE) {
+ if (DEBUG) Log.d(TAG, "Payload should be included in a single TS packet.");
+ return false;
+ }
+ stream.feedData(
+ Arrays.copyOfRange(tsData, payloadPos, pos + TS_PACKET_SIZE),
+ continuityCounter,
+ payloadStartIndicator);
+ return true;
+ }
+
+ /**
+ * Feeds MPEG-2 TS data to parse.
+ *
+ * @param tsData buffer for ATSC TS stream
+ * @param pos the offset where buffer starts
+ * @param length The length of available data
+ */
+ public void feedTSData(byte[] tsData, int pos, int length) {
+ for (; pos <= length - TS_PACKET_SIZE; pos += TS_PACKET_SIZE) {
+ feedTSPacket(tsData, pos);
+ }
+ }
+
+ /**
+ * Retrieves the channel information regardless of being well-formed.
+ *
+ * @return {@link List} of {@link TunerChannel}
+ */
+ public List<TunerChannel> getMalFormedChannels() {
+ List<TunerChannel> incompleteChannels = new ArrayList<>();
+ for (int i = 0; i < mProgramNumberHandledStatus.size(); i++) {
+ if (!mProgramNumberHandledStatus.valueAt(i)) {
+ int programNumber = mProgramNumberHandledStatus.keyAt(i);
+ List<PmtItem> pmtList = mProgramNumberToPMTMap.get(programNumber);
+ if (pmtList != null) {
+ TunerChannel tunerChannel = new TunerChannel(programNumber, pmtList);
+ incompleteChannels.add(tunerChannel);
+ }
+ }
+ }
+ return incompleteChannels;
+ }
+
+ /** Reset the versions so that data with old version number can be handled. */
+ public void resetDataVersions() {
+ for (int eitPid : mEITPids) {
+ Stream stream = mStreamMap.get(eitPid);
+ if (stream != null) {
+ stream.resetDataVersions();
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/tvinput/BaseTunerTvInputService.java b/tuner/src/com/android/tv/tuner/tvinput/BaseTunerTvInputService.java
new file mode 100644
index 00000000..e577e35e
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/BaseTunerTvInputService.java
@@ -0,0 +1,130 @@
+/*
+ * 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.TvInputService;
+import android.util.Log;
+import com.android.tv.common.feature.CommonFeatures;
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.google.android.exoplayer.audio.AudioCapabilitiesReceiver;
+import java.util.Collections;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.TimeUnit;
+
+/** {@link BaseTunerTvInputService} serves TV channels coming from a tuner device. */
+public class BaseTunerTvInputService extends TvInputService
+ implements AudioCapabilitiesReceiver.Listener {
+ private static final String TAG = "BaseTunerTvInputService";
+ private static final boolean DEBUG = false;
+
+ 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;
+
+ @Override
+ public void onCreate() {
+ if (getApplicationContext().getSystemService(Context.TV_INPUT_SERVICE) == null) {
+ Log.wtf(TAG, "Stopping because device does not have a TvInputManager");
+ this.stopSelf();
+ return;
+ }
+ super.onCreate();
+ if (DEBUG) Log.d(TAG, "onCreate");
+ mChannelDataManager = new ChannelDataManager(getApplicationContext());
+ mAudioCapabilitiesReceiver = new AudioCapabilitiesReceiver(getApplicationContext(), this);
+ mAudioCapabilitiesReceiver.register();
+ 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);
+ }
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (DEBUG) Log.d(TAG, "onDestroy");
+ super.onDestroy();
+ mChannelDataManager.release();
+ mAudioCapabilitiesReceiver.unregister();
+ }
+
+ @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 {
+ // TODO(b/65445352): Support multiple TunerSessions for multiple tuners
+ if (!allSessionsReleased()) {
+ Log.d(TAG, "abort creating an session");
+ return null;
+ }
+ final TunerSession session = new TunerSession(this, mChannelDataManager);
+ 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 boolean allSessionsReleased() {
+ for (TunerSession session : mTunerSessions) {
+ if (!session.isReleased()) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/tvinput/ChannelDataManager.java b/tuner/src/com/android/tv/tuner/tvinput/ChannelDataManager.java
new file mode 100644
index 00000000..c1d8f278
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/ChannelDataManager.java
@@ -0,0 +1,795 @@
+/*
+ * 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.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.Build;
+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.common.BaseApplication;
+import com.android.tv.common.util.PermissionUtils;
+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_LOCKED,
+ 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.
+ *
+ * <p>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 = BaseApplication.getSingletons(context).getEmbeddedTunerInputId();
+ 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);
+ releaseSafely();
+ }
+
+ public void releaseSafely() {
+ mHandlerThread.quitSafely();
+ mListener = null;
+ mChannelScanListener = null;
+ mChannelScanHandler = null;
+ }
+
+ public TunerChannel getChannel(long channelId) {
+ TunerChannel channel = mTunerChannelMap.get(channelId);
+ if (channel != null) {
+ return channel;
+ }
+ mHandler.sendEmptyMessage(MSG_BUILD_CHANNEL_MAP);
+ byte[] data = null;
+ boolean locked = false;
+ try (Cursor cursor =
+ mContext.getContentResolver()
+ .query(
+ TvContract.buildChannelUri(channelId),
+ CHANNEL_DATA_SELECTION_ARGS,
+ null,
+ null,
+ null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ locked = cursor.getInt(1) > 0;
+ data = cursor.getBlob(2);
+ }
+ }
+ if (data == null) {
+ return null;
+ }
+ channel = TunerChannel.parseFrom(data);
+ if (channel == null) {
+ return null;
+ }
+ channel.setLocked(locked);
+ 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 {
+ TunerChannel channel = TunerChannel.fromCursor(cursor);
+ if (channel != null) {
+ 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;
+ }
+ default: // fall out
+ Log.w(TAG, "unexpected case in handleMessage ( " + msg.what + " )");
+ }
+ 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));
+ 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));
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ }
+
+ applyBatch(channel.getName(), ops);
+ }
+
+ private ContentProviderOperation buildContentProviderOperation(
+ ContentProviderOperation.Builder builder, EitItem item, TunerChannel channel) {
+ if (channel != null) {
+ builder.withValue(TvContract.Programs.COLUMN_CHANNEL_ID, channel.getChannelId());
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ builder.withValue(
+ TvContract.Programs.COLUMN_RECORDING_PROHIBITED,
+ channel.isRecordingProhibited() ? 1 : 0);
+ }
+ }
+ 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_VIDEO_FORMAT, channel.getVideoFormat());
+ values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1, VERSION);
+ values.put(
+ TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG2,
+ channel.isRecordingProhibited() ? 1 : 0);
+
+ 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() {
+ if (PermissionUtils.hasAccessAllEpg(mContext)) {
+ 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();
+ }
+ }
+ } else {
+ try (Cursor cursor =
+ mContext.getContentResolver()
+ .query(
+ mChannelsUri,
+ new String[] {
+ TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1
+ },
+ null,
+ null,
+ null)) {
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ int version = cursor.getInt(0);
+ if (version != VERSION) {
+ clearChannels();
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ 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 {
+ TunerChannel tunerChannel = TunerChannel.fromCursor(cursor);
+ if (tunerChannel != null && tunerChannel.compareTo(channel) == 0) {
+ mTunerChannelIdMap.put(channel, tunerChannel.getChannelId());
+ mTunerChannelMap.put(tunerChannel.getChannelId(), channel);
+ return tunerChannel.getChannelId();
+ }
+ } 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 {
+ TunerChannel channel = TunerChannel.fromCursor(cursor);
+ if (channel != null) {
+ 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/tuner/src/com/android/tv/tuner/tvinput/EventDetector.java b/tuner/src/com/android/tv/tuner/tvinput/EventDetector.java
new file mode 100644
index 00000000..c529c6db
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/EventDetector.java
@@ -0,0 +1,349 @@
+/*
+ * 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.PsiData;
+import com.android.tv.tuner.data.PsipData;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.data.nano.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import com.android.tv.tuner.ts.TsParser;
+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 Set<Integer> mSdtProgramNumberSet = new HashSet<>();
+ private final SparseArray<TunerChannel> mChannelMap = new SparseArray<>();
+ private final SparseBooleanArray mVctCaptionTracksFound = new SparseBooleanArray();
+ private final SparseBooleanArray mEitCaptionTracksFound = new SparseBooleanArray();
+ private final List<EventListener> mEventListeners = new ArrayList<>();
+ 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 && !mEventListeners.isEmpty()) {
+ for (EventListener eventListener : mEventListeners) {
+ eventListener.onEventDetected(tunerChannel, items);
+ }
+ }
+ }
+
+ @Override
+ public void onEttPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onAllVctItemsParsed() {
+ if (!mEventListeners.isEmpty()) {
+ for (EventListener eventListener : mEventListeners) {
+ eventListener.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 (!mEventListeners.isEmpty()) {
+ for (EventListener eventListener : mEventListeners) {
+ eventListener.onChannelDetected(tunerChannel, !found);
+ }
+ }
+ }
+
+ @Override
+ public void onSdtItemParsed(
+ PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "onSdtItemParsed SDT " + 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.getServiceId();
+ tunerChannel.setAudioTracks(audioTracks);
+ tunerChannel.setCaptionTracks(captionTracks);
+ tunerChannel.setFrequency(mFrequency);
+ tunerChannel.setModulation(mModulation);
+ mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel);
+ boolean found = mSdtProgramNumberSet.contains(channelProgramNumber);
+ if (!found) {
+ mSdtProgramNumberSet.add(channelProgramNumber);
+ }
+ if (!mEventListeners.isEmpty()) {
+ for (EventListener eventListener : mEventListeners) {
+ eventListener.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}
+ */
+ public EventDetector(TunerHal usbTunerInteface) {
+ mTunerHal = usbTunerInteface;
+ }
+
+ private void reset() {
+ // TODO: Use TsParser.reset()
+ int deliverySystemType = mTunerHal.getDeliverySystemType();
+ mTsParser =
+ new TsParser(
+ mTsOutputListener,
+ TunerHal.isDvbDeliverySystem(mTunerHal.getDeliverySystemType()));
+ mPidSet.clear();
+ mVctProgramNumberSet.clear();
+ mSdtProgramNumberSet.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();
+ }
+
+ /**
+ * Registers an EventListener.
+ *
+ * @param eventListener the listener to be registered
+ */
+ public void registerListener(EventListener eventListener) {
+ if (mTsParser != null) {
+ // Resets the version numbers so that the new listener can receive the EIT items.
+ // Otherwise, each EIT session is handled only once unless there is a new version.
+ mTsParser.resetDataVersions();
+ }
+ mEventListeners.add(eventListener);
+ }
+
+ /**
+ * Unregisters an EventListener.
+ *
+ * @param eventListener the listener to be unregistered
+ */
+ public void unregisterListener(EventListener eventListener) {
+ boolean removed = mEventListeners.remove(eventListener);
+ if (!removed && DEBUG) {
+ Log.d(TAG, "Cannot unregister a non-registered listener!");
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java b/tuner/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java
new file mode 100644
index 00000000..ab05aa02
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java
@@ -0,0 +1,259 @@
+/*
+ * 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.SdtItem;
+import com.android.tv.tuner.data.PsipData.VctItem;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.data.nano.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+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 = false;
+ public static final int ALL_PROGRAM_NUMBERS = 0;
+
+ private TsParser mTsParser;
+ private final Set<Integer> mVctProgramNumberSet = new HashSet<>();
+ private final Set<Integer> mSdtProgramNumberSet = 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 final boolean mEnableDvbSignal;
+ private FileTsStreamer.StreamProvider mStreamProvider;
+ private int mProgramNumber = ALL_PROGRAM_NUMBERS;
+
+ public FileSourceEventDetector(EventDetector.EventListener listener, boolean enableDvbSignal) {
+ mEventListener = listener;
+ mEnableDvbSignal = enableDvbSignal;
+ }
+
+ /**
+ * 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, mEnableDvbSignal); // TODO: Use TsParser.reset()
+ mStreamProvider.clearPidFilter();
+ mVctProgramNumberSet.clear();
+ mSdtProgramNumberSet.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);
+ }
+ }
+
+ @Override
+ public void onSdtItemParsed(SdtItem channel, List<PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "onSdtItemParsed SDT " + 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.forDvbFile(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.getServiceId();
+ tunerChannel.setFilepath(mStreamProvider.getFilepath());
+ tunerChannel.setAudioTracks(audioTracks);
+ tunerChannel.setCaptionTracks(captionTracks);
+ mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel);
+ boolean found = mSdtProgramNumberSet.contains(channelProgramNumber);
+ if (!found) {
+ mSdtProgramNumberSet.add(channelProgramNumber);
+ }
+ if (mEventListener != null) {
+ mEventListener.onChannelDetected(tunerChannel, !found);
+ }
+ }
+ };
+}
diff --git a/tuner/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java b/tuner/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java
new file mode 100644
index 00000000..1628bcfb
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java
@@ -0,0 +1,38 @@
+/*
+ * 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/tuner/src/com/android/tv/tuner/tvinput/TunerDebug.java b/tuner/src/com/android/tv/tuner/tvinput/TunerDebug.java
new file mode 100644
index 00000000..1df0b5c3
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/TunerDebug.java
@@ -0,0 +1,147 @@
+/*
+ * 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(int count, long delta) {
+ // TODO: provide timestamp mismatch information using delta
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mVideoFrameDrop += count;
+ }
+
+ 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/tuner/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java b/tuner/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java
new file mode 100644
index 00000000..a1f0c773
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java
@@ -0,0 +1,101 @@
+/*
+ * 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.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/tuner/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java b/tuner/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java
new file mode 100644
index 00000000..b2001225
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java
@@ -0,0 +1,583 @@
+/*
+ * 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.TvContract.RecordedPrograms;
+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.support.media.tv.Program;
+import android.util.Log;
+import android.util.Pair;
+import com.android.tv.common.BaseApplication;
+import com.android.tv.common.recording.RecordingCapability;
+import com.android.tv.common.recording.RecordingStorageStatusManager;
+import com.android.tv.common.util.CommonUtils;
+import com.android.tv.tuner.DvbDeviceAccessor;
+import com.android.tv.tuner.data.PsipData;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+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.google.android.exoplayer.C;
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Random;
+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 TUNING_RETRY_INTERVAL_MS = TimeUnit.SECONDS.toMillis(4);
+ 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 static final int MSG_UPDATE_CC_INFO = 7;
+ private final RecordingCapability mCapabilities;
+
+ private static final String[] PROGRAM_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
+ };
+
+ @IntDef({STATE_IDLE, STATE_TUNING, STATE_TUNED, STATE_RECORDING})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface DvrSessionState {}
+
+ private static final int STATE_IDLE = 1;
+ private static final int STATE_TUNING = 2;
+ private static final int STATE_TUNED = 3;
+ private static final int STATE_RECORDING = 4;
+
+ private static final long CHANNEL_ID_NONE = -1;
+ private static final int MAX_TUNING_RETRY = 6;
+
+ private final Context mContext;
+ private final ChannelDataManager mChannelDataManager;
+ private final RecordingStorageStatusManager mRecordingStorageStatusManager;
+ 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 SampleExtractor mRecorder;
+ private final TunerRecordingSession mSession;
+ @DvrSessionState private int mSessionState = STATE_IDLE;
+ private final String mInputId;
+ private Uri mProgramUri;
+
+ private PsipData.EitItem mCurrenProgram;
+ private List<AtscCaptionTrack> mCaptionTracks;
+ private DvrStorageManager mDvrStorageManager;
+
+ 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);
+ mRecordingStorageStatusManager =
+ BaseApplication.getSingletons(context).getRecordingStorageStatusManager();
+ 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;
+ }
+ mHandler.obtainMessage(MSG_UPDATE_CC_INFO, new Pair<>(channel, items)).sendToTarget();
+ 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, 0, 0, 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;
+ int retryCount = msg.arg1;
+ if (DEBUG) Log.d(TAG, "Tune to " + channelUri);
+ if (doTune(channelUri)) {
+ if (mSessionState == STATE_TUNED) {
+ mSession.onTuned(channelUri);
+ } else {
+ Log.w(TAG, "Tuner stream cannot be created due to resource shortage.");
+ if (retryCount < MAX_TUNING_RETRY) {
+ Message tuneMsg =
+ mHandler.obtainMessage(
+ MSG_TUNE, retryCount + 1, 0, channelUri);
+ mHandler.sendMessageDelayed(tuneMsg, TUNING_RETRY_INTERVAL_MS);
+ } else {
+ mSession.onError(TvInputManager.RECORDING_ERROR_RESOURCE_BUSY);
+ 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 (!mRecordingStorageStatusManager.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;
+ }
+ case MSG_UPDATE_CC_INFO:
+ {
+ Pair<TunerChannel, List<EitItem>> pair =
+ (Pair<TunerChannel, List<EitItem>>) msg.obj;
+ updateCaptionTracks(pair.first, pair.second);
+ 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 (mTunerSource != null) {
+ mSourceManager.releaseDataSource(mTunerSource);
+ mTunerSource = null;
+ }
+ mDvrStorageManager = null;
+ mSessionState = STATE_IDLE;
+ mRecorderRunning = false;
+ }
+
+ private boolean doTune(Uri channelUri) {
+ if (mSessionState != STATE_IDLE && mSessionState != STATE_TUNING) {
+ 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;
+ } else if (mChannel.isRecordingProhibited()) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.w(TAG, "Failed to start recording. Not a recordable channel: " + mChannel);
+ return false;
+ }
+ if (!mRecordingStorageStatusManager.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) {
+ // Retry tuning in this case.
+ mSessionState = STATE_TUNING;
+ return true;
+ }
+ 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 =
+ mRecordingStorageStatusManager.isStorageSufficient()
+ ? new File(
+ mRecordingStorageStatusManager.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());
+ mRecordStartTime = System.currentTimeMillis();
+ mDvrStorageManager = new DvrStorageManager(mStorageDir, true);
+ mRecorder =
+ new ExoPlayerSampleExtractor(
+ Uri.EMPTY, mTunerSource, new BufferManager(mDvrStorageManager), 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 void updateCaptionTracks(TunerChannel channel, List<PsipData.EitItem> items) {
+ if (mChannel == null
+ || channel == null
+ || mChannel.compareTo(channel) != 0
+ || items == null
+ || items.isEmpty()) {
+ return;
+ }
+ PsipData.EitItem currentProgram = getCurrentProgram(items);
+ if (currentProgram == null
+ || !currentProgram.hasCaptionTrack()
+ || (mCurrenProgram != null && mCurrenProgram.compareTo(currentProgram) == 0)) {
+ return;
+ }
+ mCurrenProgram = currentProgram;
+ mCaptionTracks = new ArrayList<>(currentProgram.getCaptionTracks());
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "updated " + mCaptionTracks.size() + " caption tracks for " + currentProgram);
+ }
+ }
+
+ private PsipData.EitItem getCurrentProgram(List<PsipData.EitItem> items) {
+ for (PsipData.EitItem item : items) {
+ if (mRecordStartTime >= item.getStartTimeUtcMillis()
+ && mRecordStartTime < item.getEndTimeUtcMillis()) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ 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 && c.moveToNext()) {
+ Program result = Program.fromCursor(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, "Can not find program:" + programUri);
+ }
+ return null;
+ }
+ }
+ }
+
+ private Uri insertRecordedProgram(
+ Program program,
+ long channelId,
+ String storageUri,
+ long totalBytes,
+ long startTime,
+ long endTime) {
+ ContentValues values = new ContentValues();
+ values.put(RecordedPrograms.COLUMN_INPUT_ID, mInputId);
+ values.put(RecordedPrograms.COLUMN_CHANNEL_ID, channelId);
+ values.put(RecordedPrograms.COLUMN_RECORDING_DATA_URI, storageUri);
+ values.put(RecordedPrograms.COLUMN_RECORDING_DURATION_MILLIS, endTime - startTime);
+ values.put(RecordedPrograms.COLUMN_RECORDING_DATA_BYTES, totalBytes);
+ // startTime and endTime could be overridden by program's start and end value.
+ values.put(RecordedPrograms.COLUMN_START_TIME_UTC_MILLIS, startTime);
+ values.put(RecordedPrograms.COLUMN_END_TIME_UTC_MILLIS, endTime);
+ if (program != null) {
+ values.putAll(program.toContentValues());
+ }
+ 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"));
+ long recordEndTime =
+ (lastExtractedPositionUs == C.UNKNOWN_TIME_US)
+ ? System.currentTimeMillis()
+ : mRecordStartTime + lastExtractedPositionUs / 1000;
+ Uri uri =
+ insertRecordedProgram(
+ getRecordedProgram(),
+ mChannel.getChannelId(),
+ Uri.fromFile(mStorageDir).toString(),
+ 1024 * 1024,
+ mRecordStartTime,
+ recordEndTime);
+ if (uri == null) {
+ new DeleteRecordingTask().execute(mStorageDir);
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.e(TAG, "Inserting a recording to DB failed");
+ return;
+ }
+ mDvrStorageManager.writeCaptionInfoFiles(mCaptionTracks);
+ 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) {
+ CommonUtils.deleteDirOrFile(file);
+ }
+ return null;
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/tvinput/TunerSession.java b/tuner/src/com/android/tv/tuner/tvinput/TunerSession.java
new file mode 100644
index 00000000..c9d997f1
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/TunerSession.java
@@ -0,0 +1,341 @@
+/*
+ * 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.android.tv.common.CommonPreferences.CommonPreferencesChangedListener;
+import com.android.tv.common.util.SystemPropertiesProxy;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerPreferences;
+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.nano.Track.AtscCaptionTrack;
+import com.android.tv.tuner.util.GlobalSettingsUtils;
+import com.android.tv.tuner.util.StatusTextUtils;
+import com.google.android.exoplayer.audio.AudioCapabilities;
+
+/**
+ * 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, CommonPreferencesChangedListener {
+ 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_CLEAR_CAPTION_RENDERER = 9;
+ public static final int MSG_UI_SET_STATUS_TEXT = 10;
+ public static final int MSG_UI_TOAST_RESCAN_NEEDED = 11;
+
+ 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) {
+ 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);
+ CaptionLayout captionLayout = (CaptionLayout) mOverlayView.findViewById(R.id.caption);
+ mCaptionTrackRenderer = new CaptionTrackRenderer(captionLayout);
+ mSessionWorker = new TunerSessionWorker(context, channelDataManager, this);
+ TunerPreferences.setCommonPreferencesChangedListener(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);
+ TunerPreferences.setCommonPreferencesChangedListener(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.setText(
+ Html.fromHtml(
+ StatusTextUtils.getAudioWarningInHTML(
+ mContext.getString(
+ R.string.ut_surround_sound_disabled))));
+ } else {
+ mAudioStatusView.setText(
+ Html.fromHtml(
+ StatusTextUtils.getAudioWarningInHTML(
+ mContext.getString(
+ R.string
+ .audio_passthrough_not_supported))));
+ }
+ mAudioStatusView.setVisibility(View.VISIBLE);
+ 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_CLEAR_CAPTION_RENDERER:
+ {
+ mCaptionTrackRenderer.clear();
+ 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;
+ }
+
+ @Override
+ public void onCommonPreferencesChanged() {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_TUNER_PREFERENCES_CHANGED);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java b/tuner/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java
new file mode 100644
index 00000000..65750e08
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java
@@ -0,0 +1,1863 @@
+/*
+ * 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.Environment;
+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.text.TextUtils;
+import android.util.Log;
+import android.util.Pair;
+import android.util.SparseArray;
+import android.view.Surface;
+import android.view.accessibility.CaptioningManager;
+import com.android.tv.common.CommonPreferences.TrickplaySetting;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.TvContentRatingCache;
+import com.android.tv.common.customization.CustomizationManager;
+import com.android.tv.common.customization.CustomizationManager.TRICKPLAY_MODE;
+import com.android.tv.common.util.SystemPropertiesProxy;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.Cea708Data;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.PsipData.TvTracksInterface;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.data.nano.Channel;
+import com.android.tv.tuner.data.nano.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer;
+import com.android.tv.tuner.exoplayer.MpegTsRendererBuilder;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager.StorageManager;
+import com.android.tv.tuner.exoplayer.buffer.DvrStorageManager;
+import com.android.tv.tuner.exoplayer.buffer.TrickplayStorageManager;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsDataSourceManager;
+import com.android.tv.tuner.util.StatusTextUtils;
+import com.google.android.exoplayer.ExoPlayer;
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * {@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";
+ 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
+
+ // 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;
+ public static final int MSG_TUNER_PREFERENCES_CHANGED = 10;
+
+ // 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 static final int RELEASE_WAIT_INTERVAL_MS = 50;
+ private static final long TRICKPLAY_OFF_DURATION_MS = TimeUnit.DAYS.toMillis(14);
+
+ // Since release() is done asynchronously, synchronization between multiple TunerSessionWorker
+ // creation/release is required.
+ // This is used to guarantee that at most one active TunerSessionWorker exists at any give time.
+ private static Semaphore sActiveSessionSemaphore = new Semaphore(1);
+
+ private final Context mContext;
+ private final ChannelDataManager mChannelDataManager;
+ private final TsDataSourceManager mSourceManager;
+ private final int mMaxTrickplayBufferSizeMb;
+ private final File mTrickplayBufferDir;
+ private final @TRICKPLAY_MODE int mTrickplayModeCustomization;
+ 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 volatile boolean mTrickplayDisabledByStorageIssue;
+ private @TrickplaySetting int mTrickplaySetting;
+ private long mTrickplayExpiredMs;
+ 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 long mLastLimitInBytes;
+ private final TvContentRatingCache mTvContentRatingCache = TvContentRatingCache.getInstance();
+ private final TunerSession mSession;
+ private final boolean mHasSoftwareAudioDecoder;
+ private int mPlayerState = ExoPlayer.STATE_IDLE;
+ private long mPreparingStartTimeMs;
+ private long mBufferingStartTimeMs;
+ private long mReadyStartTimeMs;
+ private boolean mIsActiveSession;
+ private boolean mReleaseRequested; // Guarded by mReleaseLock
+ private final Object mReleaseLock = new Object();
+
+ public TunerSessionWorker(
+ Context context, ChannelDataManager channelDataManager, 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);
+ mMaxTrickplayBufferSizeMb =
+ SystemPropertiesProxy.getInt(MAX_BUFFER_SIZE_KEY, MAX_BUFFER_SIZE_DEF);
+ mTrickplayModeCustomization = CustomizationManager.getTrickplayMode(context);
+ if (mTrickplayModeCustomization
+ == CustomizationManager.TRICKPLAY_MODE_USE_EXTERNAL_STORAGE) {
+ boolean useExternalStorage =
+ Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
+ && Environment.isExternalStorageRemovable();
+ mTrickplayBufferDir = useExternalStorage ? context.getExternalCacheDir() : null;
+ } else if (mTrickplayModeCustomization == CustomizationManager.TRICKPLAY_MODE_ENABLED) {
+ mTrickplayBufferDir = context.getCacheDir();
+ } else {
+ mTrickplayBufferDir = null;
+ }
+ mTrickplayDisabledByStorageIssue = mTrickplayBufferDir == null;
+ mTrickplaySetting = TunerPreferences.getTrickplaySetting(context);
+ if (mTrickplaySetting != TunerPreferences.TRICKPLAY_SETTING_NOT_SET
+ && mTrickplayModeCustomization
+ == CustomizationManager.TRICKPLAY_MODE_USE_EXTERNAL_STORAGE) {
+ // Consider the case of Customization package updates the value of trickplay mode
+ // to TRICKPLAY_MODE_USE_EXTERNAL_STORAGE after install.
+ mTrickplaySetting = TunerPreferences.TRICKPLAY_SETTING_NOT_SET;
+ TunerPreferences.setTrickplaySetting(context, mTrickplaySetting);
+ TunerPreferences.setTrickplayExpiredMs(context, 0);
+ }
+ mTrickplayExpiredMs = TunerPreferences.getTrickplayExpiredMs(context);
+ mPreparingStartTimeMs = INVALID_TIME;
+ mBufferingStartTimeMs = INVALID_TIME;
+ mReadyStartTimeMs = INVALID_TIME;
+ // NOTE: We assume that TunerSessionWorker instance will be at most one.
+ // Only one TunerSessionWorker can be connected to FfmpegDecoderClient at any given time.
+ // connect() will return false, if there is a connected TunerSessionWorker already.
+ mHasSoftwareAudioDecoder = false; // TODO reimplement ffmpeg for google3
+ // TODO connect the ffmpeg client and report if available.
+ }
+
+ // 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) {
+ DvrStorageManager storageManager =
+ new DvrStorageManager(new File(getRecordingPath()), false);
+ List<BufferManager.TrackFormat> trackFormatList = storageManager.readTrackInfoFiles(false);
+ if (trackFormatList.isEmpty()) {
+ trackFormatList = storageManager.readTrackInfoFiles(true);
+ }
+ if (!trackFormatList.isEmpty()) {
+ BufferManager.TrackFormat trackFormat = trackFormatList.get(0);
+ Long durationUs = trackFormat.format.getLong(MediaFormat.KEY_DURATION);
+ // we need duration by milli for trickplay notification.
+ return durationUs != null ? durationUs / 1000 : null;
+ }
+ 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()");
+ synchronized (mReleaseLock) {
+ mReleaseRequested = true;
+ }
+ if (mHasSoftwareAudioDecoder) {
+ // TODO reimplement for google3
+ // Here disconnect ffmpeg
+ }
+ mChannelDataManager.setListener(null);
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.sendEmptyMessage(MSG_RELEASE);
+ }
+
+ // 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, System.identityHashCode(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, System.identityHashCode(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, System.identityHashCode(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");
+ if (mRecordingId != null) {
+ // Workaround of b/33298048: set it to 1 instead of 0.
+ mBufferStartTimeMs = mRecordStartTimeMs = 1;
+ } else {
+ mBufferStartTimeMs = mRecordStartTimeMs = 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 onClearCaptionEvent() {
+ mSession.sendUiMessage(TunerSession.MSG_UI_CLEAR_CAPTION_RENDERER);
+ }
+
+ @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() {
+ mTrickplayDisabledByStorageIssue = true;
+ sendMessage(MSG_RETRY_PLAYBACK, System.identityHashCode(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++);
+ 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);
+ if (!mIsActiveSession) {
+ // Wait until release is finished if there is a pending release.
+ try {
+ while (!sActiveSessionSemaphore.tryAcquire(
+ RELEASE_WAIT_INTERVAL_MS, TimeUnit.MILLISECONDS)) {
+ synchronized (mReleaseLock) {
+ if (mReleaseRequested) {
+ return true;
+ }
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ synchronized (mReleaseLock) {
+ if (mReleaseRequested) {
+ sActiveSessionSemaphore.release();
+ return true;
+ }
+ }
+ mIsActiveSession = true;
+ }
+ 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;
+ }
+ clearCallbacksAndMessagesSafely();
+ mChannelDataManager.removeAllCallbacksAndMessages();
+ if (channel != null) {
+ if (mTvInputManager.isParentalControlsEnabled() && channel.isLocked()) {
+ Log.i(TAG, "onTune() is failed. Channel is blocked" + channel);
+ mSession.notifyContentBlocked(TvContentRating.UNRATED);
+ return true;
+ }
+ mChannelDataManager.requestProgramsData(channel);
+ }
+ prepareTune(channel, recording);
+ // TODO: Need to refactor. notifyContentAllowed() should not be called if
+ // parental
+ // control is turned on.
+ mSession.notifyContentAllowed();
+ resetTvTracks();
+ resetPlayback();
+ 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(true);
+ stopCaptionTrack();
+ resetTvTracks();
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
+ return true;
+ }
+ case MSG_RELEASE:
+ {
+ if (DEBUG) Log.d(TAG, "MSG_RELEASE");
+ mHandler.removeCallbacksAndMessages(null);
+ stopPlayback(true);
+ stopCaptionTrack();
+ mSourceManager.release();
+ mHandler.getLooper().quitSafely();
+ if (mIsActiveSession) {
+ sActiveSessionSemaphore.release();
+ }
+ return true;
+ }
+ case MSG_RETRY_PLAYBACK:
+ {
+ if (System.identityHashCode(mPlayer) == (int) 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);
+ }
+ mChannelDataManager.removeAllCallbacksAndMessages();
+ 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(false);
+ stopCaptionTrack();
+
+ notifyVideoUnavailable(
+ TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+ Log.i(TAG, "Notify weak signal since fail to retry playback");
+
+ // 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");
+ mChannelDataManager.removeAllCallbacksAndMessages();
+ resetPlayback();
+ return true;
+ }
+ case MSG_START_PLAYBACK:
+ {
+ if (DEBUG) Log.d(TAG, "MSG_START_PLAYBACK");
+ if (mChannel != null || mRecordingId != null) {
+ startPlayback((int) 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:
+ {
+ if (mHandler.hasMessages(MSG_SCHEDULE_OF_PROGRAMS)) {
+ mHandler.sendEmptyMessage(MSG_RESCHEDULE_PROGRAMS);
+ } else {
+ 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 (mPlayer == null) {
+ Log.w(TAG, "mPlayer is null when doselectTrack is called");
+ return false;
+ }
+ if (mChannel != null || mRecordingId != null) {
+ doSelectTrack(msg.arg1, (String) msg.obj);
+ }
+ 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;
+ }
+ setTrickplayEnabledIfNeeded();
+ doTimeShiftPause();
+ return true;
+ }
+ case MSG_TIMESHIFT_RESUME:
+ {
+ if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_RESUME");
+ if (mPlayer == null) {
+ return true;
+ }
+ setTrickplayEnabledIfNeeded();
+ 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;
+ }
+ setTrickplayEnabledIfNeeded();
+ doTimeShiftSeekTo(position);
+ return true;
+ }
+ case MSG_TIMESHIFT_SET_PLAYBACKPARAMS:
+ {
+ if (mPlayer == null) {
+ return true;
+ }
+ setTrickplayEnabledIfNeeded();
+ 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_TUNER_PREFERENCES_CHANGED:
+ {
+ mHandler.removeMessages(MSG_TUNER_PREFERENCES_CHANGED);
+ @TrickplaySetting
+ int trickplaySetting = TunerPreferences.getTrickplaySetting(mContext);
+ if (trickplaySetting != mTrickplaySetting) {
+ boolean wasTrcikplayEnabled =
+ mTrickplaySetting != TunerPreferences.TRICKPLAY_SETTING_DISABLED;
+ boolean isTrickplayEnabled =
+ trickplaySetting != TunerPreferences.TRICKPLAY_SETTING_DISABLED;
+ mTrickplaySetting = trickplaySetting;
+ if (isTrickplayEnabled != wasTrcikplayEnabled) {
+ sendMessage(MSG_RESET_PLAYBACK, System.identityHashCode(mPlayer));
+ }
+ }
+ 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.setAudioTrackAndClosedCaption(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;
+ 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())));
+ }
+ mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_MESSAGE);
+ long currentTime = SystemClock.elapsedRealtime();
+ long bufferingTimeMs =
+ mBufferingStartTimeMs != INVALID_TIME
+ ? currentTime - mBufferingStartTimeMs
+ : mBufferingStartTimeMs;
+ long preparingTimeMs =
+ mPreparingStartTimeMs != INVALID_TIME
+ ? currentTime - mPreparingStartTimeMs
+ : mPreparingStartTimeMs;
+ boolean isBufferingTooLong =
+ bufferingTimeMs > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS;
+ boolean isPreparingTooLong =
+ preparingTimeMs > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS;
+ boolean isWeakSignal =
+ source != null
+ && mChannel.getType() != Channel.TunerType.TYPE_FILE
+ && (isBufferingTooLong || isPreparingTooLong);
+ if (isWeakSignal && !mReportedWeakSignal) {
+ if (!mHandler.hasMessages(MSG_RETRY_PLAYBACK)) {
+ mHandler.sendMessageDelayed(
+ mHandler.obtainMessage(
+ MSG_RETRY_PLAYBACK, System.identityHashCode(mPlayer)),
+ PLAYBACK_RETRY_DELAY_MS);
+ }
+ if (mPlayer != null) {
+ mPlayer.setAudioTrackAndClosedCaption(false);
+ }
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+ Log.i(
+ TAG,
+ "Notify weak signal due to signal check, "
+ + String.format(
+ "packetsPerSec:%d, bufferingTimeMs:%d, preparingTimeMs:%d, "
+ + "videoFrameDrop:%d",
+ (limitInBytes - mLastLimitInBytes) / TS_PACKET_SIZE,
+ bufferingTimeMs,
+ preparingTimeMs,
+ TunerDebug.getVideoFrameDrop()));
+ } 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.setAudioTrackAndClosedCaption(true);
+ }
+ }
+ mLastLimitInBytes = limitInBytes;
+ 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;
+ }
+ if (numTrackId != mPlayer.getSelectedTrack(MpegTsPlayer.TRACK_TYPE_AUDIO)) {
+ mPlayer.setSelectedTrack(MpegTsPlayer.TRACK_TYPE_AUDIO, numTrackId);
+ }
+ 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 void setTrickplayEnabledIfNeeded() {
+ if (mChannel == null
+ || mTrickplayModeCustomization != CustomizationManager.TRICKPLAY_MODE_ENABLED) {
+ return;
+ }
+ if (mTrickplaySetting == TunerPreferences.TRICKPLAY_SETTING_NOT_SET) {
+ mTrickplaySetting = TunerPreferences.TRICKPLAY_SETTING_ENABLED;
+ TunerPreferences.setTrickplaySetting(mContext, mTrickplaySetting);
+ }
+ }
+
+ private MpegTsPlayer createPlayer(AudioCapabilities capabilities) {
+ if (capabilities == null) {
+ Log.w(TAG, "No Audio Capabilities");
+ }
+ long now = System.currentTimeMillis();
+ if (mTrickplayModeCustomization == CustomizationManager.TRICKPLAY_MODE_ENABLED
+ && mTrickplaySetting == TunerPreferences.TRICKPLAY_SETTING_NOT_SET) {
+ if (mTrickplayExpiredMs == 0) {
+ mTrickplayExpiredMs = now + TRICKPLAY_OFF_DURATION_MS;
+ TunerPreferences.setTrickplayExpiredMs(mContext, mTrickplayExpiredMs);
+ } else {
+ if (mTrickplayExpiredMs < now) {
+ mTrickplaySetting = TunerPreferences.TRICKPLAY_SETTING_DISABLED;
+ TunerPreferences.setTrickplaySetting(mContext, mTrickplaySetting);
+ }
+ }
+ }
+ BufferManager bufferManager = null;
+ if (mRecordingId != null) {
+ StorageManager storageManager =
+ new DvrStorageManager(new File(getRecordingPath()), false);
+ bufferManager = new BufferManager(storageManager);
+ updateCaptionTracks(((DvrStorageManager) storageManager).readCaptionInfoFiles());
+ } else if (!mTrickplayDisabledByStorageIssue
+ && mTrickplaySetting != TunerPreferences.TRICKPLAY_SETTING_DISABLED
+ && mMaxTrickplayBufferSizeMb >= MIN_BUFFER_SIZE_DEF) {
+ bufferManager =
+ new BufferManager(
+ new TrickplayStorageManager(
+ mContext,
+ mTrickplayBufferDir,
+ 1024L * 1024 * mMaxTrickplayBufferSizeMb));
+ } else {
+ Log.w(TAG, "Trickplay is disabled.");
+ }
+ 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) {
+ synchronized (tvTracksInterface) {
+ 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 == null || 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++) {
+ // We use language information from EIT/VCT only when the player does not provide
+ // languages.
+ com.google.android.exoplayer.MediaFormat infoFromPlayer =
+ mPlayer.getTrackFormat(MpegTsPlayer.TRACK_TYPE_AUDIO, i);
+ AtscAudioTrack infoFromEit = mAudioTrackMap.get(i);
+ AtscAudioTrack infoFromVct =
+ (mChannel != null
+ && mChannel.getAudioTracks().size() == mAudioTrackMap.size()
+ && i < mChannel.getAudioTracks().size())
+ ? mChannel.getAudioTracks().get(i)
+ : null;
+ String language =
+ !TextUtils.isEmpty(infoFromPlayer.language)
+ ? infoFromPlayer.language
+ : (infoFromEit != null && infoFromEit.language != null)
+ ? infoFromEit.language
+ : (infoFromVct != null && infoFromVct.language != null)
+ ? infoFromVct.language
+ : null;
+ TvTrackInfo.Builder builder =
+ new TvTrackInfo.Builder(TvTrackInfo.TYPE_AUDIO, AUDIO_TRACK_PREFIX + i);
+ builder.setLanguage(language);
+ builder.setAudioChannelCount(infoFromPlayer.channelCount);
+ builder.setAudioSampleRate(infoFromPlayer.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(boolean removeChannelDataCallbacks) {
+ if (removeChannelDataCallbacks) {
+ 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;
+ mLastLimitInBytes = 0L;
+ mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_AUDIO_UNPLAYABLE);
+ mSession.notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE);
+ }
+ }
+
+ private void startPlayback(int playerHashCode) {
+ // TODO: provide hasAudio()/hasVideo() for play recordings.
+ if (mPlayer == null || System.identityHashCode(mPlayer) != playerHashCode) {
+ 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()))
+ && mChannel.getType() != Channel.TunerType.TYPE_NETWORK) {
+ // If the channel is from network, skip this part since the video and audio tracks
+ // information for channels from network are more reliable in the extractor. Otherwise,
+ // tracks haven't been detected in the extractor. Try again.
+ sendMessage(MSG_RETRY_PLAYBACK, System.identityHashCode(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 && mPlayer.hasAudio() && !mPlayer.hasVideo()) {
+ 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);
+ MpegTsPlayer player = createPlayer(mAudioCapabilities);
+ player.setCaptionServiceNumber(Cea708Data.EMPTY_SERVICE_NUMBER);
+ player.setVideoEventListener(this);
+ player.setCaptionServiceNumber(
+ mCaptionTrack != null
+ ? mCaptionTrack.serviceNumber
+ : Cea708Data.EMPTY_SERVICE_NUMBER);
+ if (!player.prepare(mContext, mChannel, mHasSoftwareAudioDecoder, 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);
+ Log.i(TAG, "Notify weak signal due to player preparation failure");
+ mHandler.sendMessageDelayed(
+ mHandler.obtainMessage(
+ MSG_RETRY_PLAYBACK, System.identityHashCode(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;
+ long oldTimestamp;
+ timestamp = SystemClock.elapsedRealtime();
+ stopPlayback(false);
+ 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;
+ if (mRecordingId != null) {
+ // Workaround of b/33298048: set it to 1 instead of 0.
+ mBufferStartTimeMs = mRecordStartTimeMs = 1;
+ } else {
+ mBufferStartTimeMs = mRecordStartTimeMs = 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);
+ }
+
+ @SuppressWarnings("NarrowingCompoundAssignment")
+ 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.setAudioTrackAndClosedCaption(true);
+ return;
+ }
+ } else if (seekPositionMs > System.currentTimeMillis() - mRecordStartTimeMs) {
+ // Stops trickplay when FF requested the position later than current position.
+ // If RW trickplay requested the position later than current position,
+ // continue trickplay.
+ if (mPlaybackParams.getSpeed() > 0.0f) {
+ mPlayer.seekTo(System.currentTimeMillis() - mRecordStartTimeMs);
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setAudioTrackAndClosedCaption(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.setAudioTrackAndClosedCaption(true);
+ }
+
+ private void doTimeShiftResume() {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setPlayWhenReady(true);
+ mPlayer.setAudioTrackAndClosedCaption(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.setAudioTrackAndClosedCaption(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.setAudioTrackAndClosedCaption(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.TunerType.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 || ratings.length == 0) {
+ ratings = new TvContentRating[] {TvContentRating.UNRATED};
+ }
+ 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) {
+ clearCallbacksAndMessagesSafely();
+ stopPlayback(true);
+ resetTvTracks();
+ if (contentRating != null) {
+ mSession.notifyContentBlocked(contentRating);
+ }
+ mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS, PARENTAL_CONTROLS_INTERVAL_MS);
+ } else {
+ clearCallbacksAndMessagesSafely();
+ 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);
+ }
+ }
+
+ @WorkerThread
+ private void clearCallbacksAndMessagesSafely() {
+ // If MSG_RELEASE is removed, TunerSessionWorker will hang forever.
+ // Do not remove messages, after release is requested from MainThread.
+ synchronized (mReleaseLock) {
+ if (!mReleaseRequested) {
+ mHandler.removeCallbacksAndMessages(null);
+ }
+ }
+ }
+
+ 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/tuner/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java b/tuner/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java
new file mode 100644
index 00000000..cdcc00d5
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java
@@ -0,0 +1,176 @@
+/*
+ * 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 android.util.Log;
+import com.android.tv.common.BaseApplication;
+import com.android.tv.common.recording.RecordingStorageStatusManager;
+import com.android.tv.common.util.CommonUtils;
+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 static final String TAG = "TunerStorageCleanUpService";
+
+ private CleanUpStorageTask mTask;
+
+ @Override
+ public void onCreate() {
+ if (getApplicationContext().getSystemService(Context.TV_INPUT_SERVICE) == null) {
+ Log.wtf(TAG, "Stopping because device does not have a TvInputManager");
+ this.stopSelf();
+ return;
+ }
+ 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 static final String[] mProjection = {
+ TvContract.RecordedPrograms.COLUMN_PACKAGE_NAME,
+ TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI
+ };
+ private static final long ELAPSED_MILLIS_TO_DELETE = TimeUnit.DAYS.toMillis(1);
+
+ private final Context mContext;
+ private final RecordingStorageStatusManager 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 =
+ BaseApplication.getSingletons(context).getRecordingStorageStatusManager();
+ 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 (!CommonUtils.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()
+ == RecordingStorageStatusManager.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.
+ CommonUtils.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/tuner/src/com/android/tv/tuner/util/ByteArrayBuffer.java b/tuner/src/com/android/tv/tuner/util/ByteArrayBuffer.java
new file mode 100644
index 00000000..00ba039a
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/util/ByteArrayBuffer.java
@@ -0,0 +1,155 @@
+/*
+ * $HeadURL: http://svn.apache.org/repos/asf/httpcomponents/httpcore/trunk/module-main/src/main/java/org/apache/http/util/ByteArrayBuffer.java $
+ * $Revision: 496070 $
+ * $Date: 2007-01-14 04:18:34 -0800 (Sun, 14 Jan 2007) $
+ *
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ * For the license checker
+ * Licensed under the Apache License, Version 2.0
+ *
+ */
+
+package com.android.tv.tuner.util;
+
+/** An expandable byte buffer built on byte array. */
+public final class ByteArrayBuffer {
+
+ private byte[] buffer;
+ private int len;
+
+ public ByteArrayBuffer(int capacity) {
+ super();
+ if (capacity < 0) {
+ throw new IllegalArgumentException("Buffer capacity may not be negative");
+ }
+ this.buffer = new byte[capacity];
+ }
+
+ private void expand(int newlen) {
+ byte newbuffer[] = new byte[Math.max(this.buffer.length << 1, newlen)];
+ System.arraycopy(this.buffer, 0, newbuffer, 0, this.len);
+ this.buffer = newbuffer;
+ }
+
+ public void append(final byte[] b, int off, int len) {
+ if (b == null) {
+ return;
+ }
+ if ((off < 0)
+ || (off > b.length)
+ || (len < 0)
+ || ((off + len) < 0)
+ || ((off + len) > b.length)) {
+ throw new IndexOutOfBoundsException();
+ }
+ if (len == 0) {
+ return;
+ }
+ int newlen = this.len + len;
+ if (newlen > this.buffer.length) {
+ expand(newlen);
+ }
+ System.arraycopy(b, off, this.buffer, this.len, len);
+ this.len = newlen;
+ }
+
+ public void append(int b) {
+ int newlen = this.len + 1;
+ if (newlen > this.buffer.length) {
+ expand(newlen);
+ }
+ this.buffer[this.len] = (byte) b;
+ this.len = newlen;
+ }
+
+ public void append(final char[] b, int off, int len) {
+ if (b == null) {
+ return;
+ }
+ if ((off < 0)
+ || (off > b.length)
+ || (len < 0)
+ || ((off + len) < 0)
+ || ((off + len) > b.length)) {
+ throw new IndexOutOfBoundsException();
+ }
+ if (len == 0) {
+ return;
+ }
+ int oldlen = this.len;
+ int newlen = oldlen + len;
+ if (newlen > this.buffer.length) {
+ expand(newlen);
+ }
+ for (int i1 = off, i2 = oldlen; i2 < newlen; i1++, i2++) {
+ this.buffer[i2] = (byte) b[i1];
+ }
+ this.len = newlen;
+ }
+
+ public void clear() {
+ this.len = 0;
+ }
+
+ public byte[] toByteArray() {
+ byte[] b = new byte[this.len];
+ if (this.len > 0) {
+ System.arraycopy(this.buffer, 0, b, 0, this.len);
+ }
+ return b;
+ }
+
+ public int byteAt(int i) {
+ return this.buffer[i];
+ }
+
+ public int capacity() {
+ return this.buffer.length;
+ }
+
+ public int length() {
+ return this.len;
+ }
+
+ public byte[] buffer() {
+ return this.buffer;
+ }
+
+ public void setLength(int len) {
+ if (len < 0 || len > this.buffer.length) {
+ throw new IndexOutOfBoundsException();
+ }
+ this.len = len;
+ }
+
+ public boolean isEmpty() {
+ return this.len == 0;
+ }
+
+ public boolean isFull() {
+ return this.len == this.buffer.length;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/util/ConvertUtils.java b/tuner/src/com/android/tv/tuner/util/ConvertUtils.java
new file mode 100644
index 00000000..4b7fbdae
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/util/ConvertUtils.java
@@ -0,0 +1,33 @@
+/*
+ * 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.util;
+
+/** Utility class for converting date and time. */
+public class ConvertUtils {
+ // Time diff between 1.1.1970 00:00:00 and 6.1.1980 00:00:00
+ private static final long DIFF_BETWEEN_UNIX_EPOCH_AND_GPS = 315964800;
+
+ private ConvertUtils() {}
+
+ public static long convertGPSTimeToUnixEpoch(long gpsTime) {
+ return gpsTime + DIFF_BETWEEN_UNIX_EPOCH_AND_GPS;
+ }
+
+ public static long convertUnixEpochToGPSTime(long epochTime) {
+ return epochTime - DIFF_BETWEEN_UNIX_EPOCH_AND_GPS;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/util/GlobalSettingsUtils.java b/tuner/src/com/android/tv/tuner/util/GlobalSettingsUtils.java
new file mode 100644
index 00000000..98463f3b
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/util/GlobalSettingsUtils.java
@@ -0,0 +1,34 @@
+/*
+ * 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.util;
+
+import android.content.Context;
+import android.provider.Settings;
+
+/** Utility class that get information of global settings. */
+public class GlobalSettingsUtils {
+ // Since global surround setting is hided, add the related variable here for checking surround
+ // sound setting when the audio is unavailable. Remove this workaround after b/31254857 fixed.
+ private static final String ENCODED_SURROUND_OUTPUT = "encoded_surround_output";
+ public static final int ENCODED_SURROUND_OUTPUT_NEVER = 1;
+
+ private GlobalSettingsUtils() {}
+
+ public static int getEncodedSurroundOutputSettings(Context context) {
+ return Settings.Global.getInt(context.getContentResolver(), ENCODED_SURROUND_OUTPUT, 0);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/util/Ints.java b/tuner/src/com/android/tv/tuner/util/Ints.java
new file mode 100644
index 00000000..22c6bbb9
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/util/Ints.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2017 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.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Static utility methods pertaining to int primitives. (Referred Guava's Ints class) */
+public class Ints {
+ private Ints() {}
+
+ public static int[] toArray(List<Integer> integerList) {
+ int[] intArray = new int[integerList.size()];
+ int i = 0;
+ for (Integer data : integerList) {
+ intArray[i++] = data;
+ }
+ return intArray;
+ }
+
+ public static List<Integer> asList(int[] intArray) {
+ List<Integer> integerList = new ArrayList<>(intArray.length);
+ for (int data : intArray) {
+ integerList.add(data);
+ }
+ return integerList;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/util/StatusTextUtils.java b/tuner/src/com/android/tv/tuner/util/StatusTextUtils.java
new file mode 100644
index 00000000..84e2fc5a
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/util/StatusTextUtils.java
@@ -0,0 +1,137 @@
+/*
+ * 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.util;
+
+import java.util.Locale;
+
+/** Utility class for tuner status messages. */
+public class StatusTextUtils {
+ private static final int PACKETS_PER_SEC_YELLOW = 1500;
+ private static final int PACKETS_PER_SEC_RED = 1000;
+ private static final int AUDIO_POSITION_MS_RATE_DIFF_YELLOW = 100;
+ private static final int AUDIO_POSITION_MS_RATE_DIFF_RED = 200;
+ private static final String COLOR_RED = "red";
+ private static final String COLOR_YELLOW = "yellow";
+ private static final String COLOR_GREEN = "green";
+ private static final String COLOR_GRAY = "gray";
+
+ private StatusTextUtils() {}
+
+ /**
+ * Returns tuner status warning message in HTML.
+ *
+ * <p>This is only called for debuging and always shown in english.
+ */
+ public static String getStatusWarningInHTML(
+ long packetsPerSec,
+ int videoFrameDrop,
+ int bytesInQueue,
+ long audioPositionUs,
+ long audioPositionUsRate,
+ long audioPtsUs,
+ long audioPtsUsRate,
+ long videoPtsUs,
+ long videoPtsUsRate) {
+ StringBuffer buffer = new StringBuffer();
+
+ // audioPosition should go in rate of 1000ms.
+ long audioPositionMsRate = audioPositionUsRate / 1000;
+ String audioPositionColor;
+ if (Math.abs(audioPositionMsRate - 1000) > AUDIO_POSITION_MS_RATE_DIFF_RED) {
+ audioPositionColor = COLOR_RED;
+ } else if (Math.abs(audioPositionMsRate - 1000) > AUDIO_POSITION_MS_RATE_DIFF_YELLOW) {
+ audioPositionColor = COLOR_YELLOW;
+ } else {
+ audioPositionColor = COLOR_GRAY;
+ }
+ buffer.append(String.format(Locale.US, "<font color=%s>", audioPositionColor));
+ buffer.append(
+ String.format(
+ Locale.US,
+ "audioPositionMs: %d (%d)<br>",
+ audioPositionUs / 1000,
+ audioPositionMsRate));
+ buffer.append("</font>\n");
+ buffer.append("<font color=" + COLOR_GRAY + ">");
+ buffer.append(
+ String.format(
+ Locale.US,
+ "audioPtsMs: %d (%d, %d)<br>",
+ audioPtsUs / 1000,
+ audioPtsUsRate / 1000,
+ (audioPtsUs - audioPositionUs) / 1000));
+ buffer.append(
+ String.format(
+ Locale.US,
+ "videoPtsMs: %d (%d, %d)<br>",
+ videoPtsUs / 1000,
+ videoPtsUsRate / 1000,
+ (videoPtsUs - audioPositionUs) / 1000));
+ buffer.append("</font>\n");
+
+ appendStatusLine(buffer, "KbytesInQueue", bytesInQueue / 1000, 1, 10);
+ buffer.append("<br/>");
+ appendErrorStatusLine(buffer, "videoFrameDrop", videoFrameDrop, 0, 2);
+ buffer.append("<br/>");
+ appendStatusLine(
+ buffer,
+ "packetsPerSec",
+ packetsPerSec,
+ PACKETS_PER_SEC_RED,
+ PACKETS_PER_SEC_YELLOW);
+ return buffer.toString();
+ }
+
+ /** Returns audio unavailable warning message in HTML. */
+ public static String getAudioWarningInHTML(String msg) {
+ return String.format("<font color=%s>%s</font>\n", COLOR_YELLOW, msg);
+ }
+
+ private static void appendStatusLine(
+ StringBuffer buffer, String factorName, long value, int minRed, int minYellow) {
+ buffer.append("<font color=");
+ if (value <= minRed) {
+ buffer.append(COLOR_RED);
+ } else if (value <= minYellow) {
+ buffer.append(COLOR_YELLOW);
+ } else {
+ buffer.append(COLOR_GREEN);
+ }
+ buffer.append(">");
+ buffer.append(factorName);
+ buffer.append(" : ");
+ buffer.append(value);
+ buffer.append("</font>");
+ }
+
+ private static void appendErrorStatusLine(
+ StringBuffer buffer, String factorName, int value, int minGreen, int minYellow) {
+ buffer.append("<font color=");
+ if (value <= minGreen) {
+ buffer.append(COLOR_GREEN);
+ } else if (value <= minYellow) {
+ buffer.append(COLOR_YELLOW);
+ } else {
+ buffer.append(COLOR_RED);
+ }
+ buffer.append(">");
+ buffer.append(factorName);
+ buffer.append(" : ");
+ buffer.append(value);
+ buffer.append("</font>");
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/util/TunerInputInfoUtils.java b/tuner/src/com/android/tv/tuner/util/TunerInputInfoUtils.java
new file mode 100644
index 00000000..fad71335
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/util/TunerInputInfoUtils.java
@@ -0,0 +1,112 @@
+/*
+ * 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.util;
+
+import android.annotation.TargetApi;
+import android.content.ComponentName;
+import android.content.Context;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvInputManager;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import android.util.Pair;
+import com.android.tv.common.BaseApplication;
+import com.android.tv.common.BuildConfig;
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerHal;
+
+/** Utility class for providing tuner input info. */
+public class TunerInputInfoUtils {
+ private static final String TAG = "TunerInputInfoUtils";
+ private static final boolean DEBUG = false;
+
+ /** Builds tuner input's info. */
+ @Nullable
+ @TargetApi(Build.VERSION_CODES.N)
+ public static TvInputInfo buildTunerInputInfo(Context context) {
+ Pair<Integer, Integer> tunerTypeAndCount = TunerHal.getTunerTypeAndCount(context);
+ if (tunerTypeAndCount.first == null || tunerTypeAndCount.second == 0) {
+ return null;
+ }
+ int inputLabelId = 0;
+ switch (tunerTypeAndCount.first) {
+ case TunerHal.TUNER_TYPE_BUILT_IN:
+ inputLabelId = R.string.bt_app_name;
+ break;
+ case TunerHal.TUNER_TYPE_USB:
+ inputLabelId = R.string.ut_app_name;
+ break;
+ case TunerHal.TUNER_TYPE_NETWORK:
+ inputLabelId = R.string.nt_app_name;
+ break;
+ }
+ try {
+ String inputId = BaseApplication.getSingletons(context).getEmbeddedTunerInputId();
+ TvInputInfo.Builder builder =
+ new TvInputInfo.Builder(context, ComponentName.unflattenFromString(inputId));
+ return builder.setLabel(inputLabelId)
+ .setCanRecord(CommonFeatures.DVR.isEnabled(context))
+ .setTunerCount(tunerTypeAndCount.second)
+ .build();
+ } catch (IllegalArgumentException | NullPointerException e) {
+ // BaseTunerTvInputService is not enabled.
+ return null;
+ }
+ }
+
+ /**
+ * Updates tuner input's info.
+ *
+ * @param context {@link Context} instance
+ */
+ public static void updateTunerInputInfo(Context context) {
+ final Context appContext = context.getApplicationContext();
+ if (!BuildConfig.NO_JNI_TEST && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ new AsyncTask<Void, Void, TvInputInfo>() {
+ @Override
+ protected TvInputInfo doInBackground(Void... params) {
+ if (DEBUG) Log.d(TAG, "updateTunerInputInfo()");
+ return buildTunerInputInfo(appContext);
+ }
+
+ @Override
+ @TargetApi(Build.VERSION_CODES.N)
+ protected void onPostExecute(TvInputInfo info) {
+ if (info != null) {
+ ((TvInputManager) appContext.getSystemService(Context.TV_INPUT_SERVICE))
+ .updateTvInputInfo(info);
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "TvInputInfo ["
+ + info.loadLabel(appContext)
+ + "] updated: "
+ + info.toString());
+ }
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "Updating tuner input info failed. Input is not ready yet.");
+ }
+ }
+ }
+ }.execute();
+ }
+ }
+}
diff --git a/tuner/src/com/google/android/exoplayer/MediaFormatUtil.java b/tuner/src/com/google/android/exoplayer/MediaFormatUtil.java
new file mode 100644
index 00000000..c7571b23
--- /dev/null
+++ b/tuner/src/com/google/android/exoplayer/MediaFormatUtil.java
@@ -0,0 +1,96 @@
+/*
+ * 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.google.android.exoplayer;
+
+import android.support.annotation.Nullable;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+
+/** {@link MediaFormat} creation helper util */
+public class MediaFormatUtil {
+
+ /**
+ * Creates {@link MediaFormat} from {@link android.media.MediaFormat}. Since {@link
+ * com.google.android.exoplayer.TrackRenderer} uses {@link MediaFormat}, {@link
+ * android.media.MediaFormat} should be converted to be used with ExoPlayer.
+ */
+ public static MediaFormat createMediaFormat(android.media.MediaFormat format) {
+ String mimeType = format.getString(android.media.MediaFormat.KEY_MIME);
+ String language = getOptionalStringV16(format, android.media.MediaFormat.KEY_LANGUAGE);
+ int maxInputSize =
+ getOptionalIntegerV16(format, android.media.MediaFormat.KEY_MAX_INPUT_SIZE);
+ int width = getOptionalIntegerV16(format, android.media.MediaFormat.KEY_WIDTH);
+ int height = getOptionalIntegerV16(format, android.media.MediaFormat.KEY_HEIGHT);
+ int rotationDegrees = getOptionalIntegerV16(format, "rotation-degrees");
+ int channelCount =
+ getOptionalIntegerV16(format, android.media.MediaFormat.KEY_CHANNEL_COUNT);
+ int sampleRate = getOptionalIntegerV16(format, android.media.MediaFormat.KEY_SAMPLE_RATE);
+ int encoderDelay = getOptionalIntegerV16(format, "encoder-delay");
+ int encoderPadding = getOptionalIntegerV16(format, "encoder-padding");
+ ArrayList<byte[]> initializationData = new ArrayList<>();
+ for (int i = 0; format.containsKey("csd-" + i); i++) {
+ ByteBuffer buffer = format.getByteBuffer("csd-" + i);
+ byte[] data = new byte[buffer.limit()];
+ buffer.get(data);
+ initializationData.add(data);
+ buffer.flip();
+ }
+ long durationUs =
+ format.containsKey(android.media.MediaFormat.KEY_DURATION)
+ ? format.getLong(android.media.MediaFormat.KEY_DURATION)
+ : C.UNKNOWN_TIME_US;
+ int pcmEncoding =
+ MimeTypes.AUDIO_RAW.equals(mimeType) ? C.ENCODING_PCM_16BIT : MediaFormat.NO_VALUE;
+ MediaFormat mediaFormat =
+ new MediaFormat(
+ null, // trackId
+ mimeType,
+ MediaFormat.NO_VALUE,
+ maxInputSize,
+ durationUs,
+ width,
+ height,
+ rotationDegrees,
+ MediaFormat.NO_VALUE,
+ channelCount,
+ sampleRate,
+ language,
+ MediaFormat.OFFSET_SAMPLE_RELATIVE,
+ initializationData,
+ false,
+ MediaFormat.NO_VALUE,
+ MediaFormat.NO_VALUE,
+ pcmEncoding,
+ encoderDelay,
+ encoderPadding,
+ null, // projectionData
+ MediaFormat.NO_VALUE,
+ null // colorValue
+ );
+ mediaFormat.setFrameworkFormatV16(format);
+ return mediaFormat;
+ }
+
+ @Nullable
+ private static String getOptionalStringV16(android.media.MediaFormat format, String key) {
+ return format.containsKey(key) ? format.getString(key) : null;
+ }
+
+ private static int getOptionalIntegerV16(android.media.MediaFormat format, String key) {
+ return format.containsKey(key) ? format.getInteger(key) : MediaFormat.NO_VALUE;
+ }
+}
diff --git a/tuner/src/com/google/android/exoplayer/MediaSoftwareCodecUtil.java b/tuner/src/com/google/android/exoplayer/MediaSoftwareCodecUtil.java
new file mode 100644
index 00000000..cf74f106
--- /dev/null
+++ b/tuner/src/com/google/android/exoplayer/MediaSoftwareCodecUtil.java
@@ -0,0 +1,273 @@
+/*
+ * 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.google.android.exoplayer;
+
+import android.annotation.TargetApi;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.Pair;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.util.HashMap;
+
+/**
+ * Mostly copied from {@link com.google.android.exoplayer.MediaCodecUtil} in order to choose
+ * software codec over hardware codec.
+ */
+public class MediaSoftwareCodecUtil {
+ private static final String TAG = "MediaSoftwareCodecUtil";
+
+ /**
+ * Thrown when an error occurs querying the device for its underlying media capabilities.
+ *
+ * <p>Such failures are not expected in normal operation and are normally temporary (e.g. if the
+ * mediaserver process has crashed and is yet to restart).
+ */
+ public static class DecoderQueryException extends Exception {
+
+ private DecoderQueryException(Throwable cause) {
+ super("Failed to query underlying media codecs", cause);
+ }
+ }
+
+ private static final HashMap<CodecKey, Pair<String, MediaCodecInfo.CodecCapabilities>>
+ sSwCodecs = new HashMap<>();
+
+ /** Gets information about the software decoder that will be used for a given mime type. */
+ public static DecoderInfo getSoftwareDecoderInfo(String mimeType, boolean secure)
+ throws DecoderQueryException {
+ // TODO: Add a test for this method.
+ Pair<String, MediaCodecInfo.CodecCapabilities> info =
+ getMediaSoftwareCodecInfo(mimeType, secure);
+ if (info == null) {
+ return null;
+ }
+ return new DecoderInfo(info.first, info.second);
+ }
+
+ /** Returns the name of the software decoder and its capabilities for the given mimeType. */
+ private static synchronized Pair<String, MediaCodecInfo.CodecCapabilities>
+ getMediaSoftwareCodecInfo(String mimeType, boolean secure)
+ throws DecoderQueryException {
+ CodecKey key = new CodecKey(mimeType, secure);
+ if (sSwCodecs.containsKey(key)) {
+ return sSwCodecs.get(key);
+ }
+ MediaCodecListCompat mediaCodecList = new MediaCodecListCompatV21(secure);
+ Pair<String, MediaCodecInfo.CodecCapabilities> codecInfo =
+ getMediaSoftwareCodecInfo(key, mediaCodecList);
+ if (secure && codecInfo == null) {
+ // Some devices don't list secure decoders on API level 21. Try the legacy path.
+ mediaCodecList = new MediaCodecListCompatV16();
+ codecInfo = getMediaSoftwareCodecInfo(key, mediaCodecList);
+ if (codecInfo != null) {
+ Log.w(
+ TAG,
+ "MediaCodecList API didn't list secure decoder for: "
+ + mimeType
+ + ". Assuming: "
+ + codecInfo.first);
+ }
+ }
+ return codecInfo;
+ }
+
+ private static Pair<String, MediaCodecInfo.CodecCapabilities> getMediaSoftwareCodecInfo(
+ CodecKey key, MediaCodecListCompat mediaCodecList) throws DecoderQueryException {
+ try {
+ return getMediaSoftwareCodecInfoInternal(key, mediaCodecList);
+ } catch (Exception e) {
+ // If the underlying mediaserver is in a bad state, we may catch an
+ // IllegalStateException or an IllegalArgumentException here.
+ throw new DecoderQueryException(e);
+ }
+ }
+
+ private static Pair<String, MediaCodecInfo.CodecCapabilities> getMediaSoftwareCodecInfoInternal(
+ CodecKey key, MediaCodecListCompat mediaCodecList) {
+ String mimeType = key.mimeType;
+ int numberOfCodecs = mediaCodecList.getCodecCount();
+ boolean secureDecodersExplicit = mediaCodecList.secureDecodersExplicit();
+ // Note: MediaCodecList is sorted by the framework such that the best decoders come first.
+ for (int i = 0; i < numberOfCodecs; i++) {
+ MediaCodecInfo info = mediaCodecList.getCodecInfoAt(i);
+ String codecName = info.getName();
+ if (!info.isEncoder()
+ && codecName.startsWith("OMX.google.")
+ && (secureDecodersExplicit || !codecName.endsWith(".secure"))) {
+ String[] supportedTypes = info.getSupportedTypes();
+ for (String supportedType : supportedTypes) {
+ if (supportedType.equalsIgnoreCase(mimeType)) {
+ MediaCodecInfo.CodecCapabilities capabilities =
+ info.getCapabilitiesForType(supportedType);
+ boolean secure =
+ mediaCodecList.isSecurePlaybackSupported(
+ key.mimeType, capabilities);
+ if (!secureDecodersExplicit) {
+ // Cache variants for both insecure and (if we think it's supported)
+ // secure playback.
+ sSwCodecs.put(
+ key.secure ? new CodecKey(mimeType, false) : key,
+ Pair.create(codecName, capabilities));
+ if (secure) {
+ sSwCodecs.put(
+ key.secure ? key : new CodecKey(mimeType, true),
+ Pair.create(codecName + ".secure", capabilities));
+ }
+ } else {
+ // Only cache this variant. If both insecure and secure decoders are
+ // available, they should both be listed separately.
+ sSwCodecs.put(
+ key.secure == secure ? key : new CodecKey(mimeType, secure),
+ Pair.create(codecName, capabilities));
+ }
+ if (sSwCodecs.containsKey(key)) {
+ return sSwCodecs.get(key);
+ }
+ }
+ }
+ }
+ }
+ sSwCodecs.put(key, null);
+ return null;
+ }
+
+ private interface MediaCodecListCompat {
+
+ /** Returns the number of codecs in the list. */
+ int getCodecCount();
+
+ /**
+ * Returns the info at the specified index in the list.
+ *
+ * @param index The index.
+ */
+ MediaCodecInfo getCodecInfoAt(int index);
+
+ /** Returns whether secure decoders are explicitly listed, if present. */
+ boolean secureDecodersExplicit();
+
+ /**
+ * Returns true if secure playback is supported for the given {@link
+ * android.media.MediaCodecInfo.CodecCapabilities}, which should have been obtained from a
+ * {@link MediaCodecInfo} obtained from this list.
+ */
+ boolean isSecurePlaybackSupported(
+ String mimeType, MediaCodecInfo.CodecCapabilities capabilities);
+ }
+
+ @TargetApi(21)
+ private static final class MediaCodecListCompatV21 implements MediaCodecListCompat {
+
+ private final int codecKind;
+
+ private MediaCodecInfo[] mediaCodecInfos;
+
+ public MediaCodecListCompatV21(boolean includeSecure) {
+ codecKind = includeSecure ? MediaCodecList.ALL_CODECS : MediaCodecList.REGULAR_CODECS;
+ }
+
+ @Override
+ public int getCodecCount() {
+ ensureMediaCodecInfosInitialized();
+ return mediaCodecInfos.length;
+ }
+
+ @Override
+ public MediaCodecInfo getCodecInfoAt(int index) {
+ ensureMediaCodecInfosInitialized();
+ return mediaCodecInfos[index];
+ }
+
+ @Override
+ public boolean secureDecodersExplicit() {
+ return true;
+ }
+
+ @Override
+ public boolean isSecurePlaybackSupported(
+ String mimeType, MediaCodecInfo.CodecCapabilities capabilities) {
+ return capabilities.isFeatureSupported(
+ MediaCodecInfo.CodecCapabilities.FEATURE_SecurePlayback);
+ }
+
+ private void ensureMediaCodecInfosInitialized() {
+ if (mediaCodecInfos == null) {
+ mediaCodecInfos = new MediaCodecList(codecKind).getCodecInfos();
+ }
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private static final class MediaCodecListCompatV16 implements MediaCodecListCompat {
+
+ @Override
+ public int getCodecCount() {
+ return MediaCodecList.getCodecCount();
+ }
+
+ @Override
+ public MediaCodecInfo getCodecInfoAt(int index) {
+ return MediaCodecList.getCodecInfoAt(index);
+ }
+
+ @Override
+ public boolean secureDecodersExplicit() {
+ return false;
+ }
+
+ @Override
+ public boolean isSecurePlaybackSupported(
+ String mimeType, MediaCodecInfo.CodecCapabilities capabilities) {
+ // Secure decoders weren't explicitly listed prior to API level 21. We assume that
+ // a secure H264 decoder exists.
+ return MimeTypes.VIDEO_H264.equals(mimeType);
+ }
+ }
+
+ private static final class CodecKey {
+
+ public final String mimeType;
+ public final boolean secure;
+
+ public CodecKey(String mimeType, boolean secure) {
+ this.mimeType = mimeType;
+ this.secure = secure;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((mimeType == null) ? 0 : mimeType.hashCode());
+ result = 2 * result + (secure ? 0 : 1);
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof CodecKey)) {
+ return false;
+ }
+ CodecKey other = (CodecKey) obj;
+ return TextUtils.equals(mimeType, other.mimeType) && secure == other.secure;
+ }
+ }
+}
diff --git a/tuner/src/com/mediatek/tunerservice/IMtkTuner.aidl b/tuner/src/com/mediatek/tunerservice/IMtkTuner.aidl
new file mode 100644
index 00000000..b15c736a
--- /dev/null
+++ b/tuner/src/com/mediatek/tunerservice/IMtkTuner.aidl
@@ -0,0 +1,27 @@
+/*
+ * 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.mediatek.tunerservice;
+
+interface IMtkTuner {
+ boolean tune(int frequency, String modulation, int timeOutMs);
+ void addPidFilter(int pid, int filterType);
+ void closeAllPidFilters();
+ void stopTune();
+ byte[] getTsData(int maxDataSize, int timeOutMs);
+ void setHasPendingTune(boolean hasPendingTune);
+ void release();
+}