diff options
Diffstat (limited to 'tuner/src/com')
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 <frequency> <modulation>". + * @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 -> 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 -> 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 -> 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 & 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(); +} |