diff options
author | Nick Chalko <nchalko@google.com> | 2018-01-17 11:15:16 -0800 |
---|---|---|
committer | Nick Chalko <nchalko@google.com> | 2018-01-17 11:20:37 -0800 |
commit | 38fef3bf253578f518d1bc727da4afb263194398 (patch) | |
tree | 09a06234eda7c54216bca773b6d8407eafe0722d /src/com | |
parent | c9889d13513e26649a7708cf2d0562cb592d441a (diff) | |
download | TV-38fef3bf253578f518d1bc727da4afb263194398.tar.gz |
Fix broken build
This reverts
c9889d1 Update aosp build to use a snapshot of exoplyer. by nchalko · 5
hours ago master
8952aa7 Clean format by nchalko · 20 hours ago
ba3fb16 Merge "Use a snapshot of exoplayer" by TreeHugger Robot · 18
hours ago
ff75e39 Project import generated by Copybara. by Live Channels Team · 22
hours ago
9737fc2 Use a snapshot of exoplayer by Nick Chalko · 20 hours ago
4a5144a Project import generated by Copybara. by Live Channels Team · 6
days ago
Bug: 72092981
Bug: 69474774
Change-Id: Ie756857c10bf052c60b6442c3d61252f65b49143
Diffstat (limited to 'src/com')
264 files changed, 28995 insertions, 3169 deletions
diff --git a/src/com/android/exoplayer/MediaFormatUtil.java b/src/com/android/exoplayer/MediaFormatUtil.java new file mode 100644 index 00000000..151c6dd5 --- /dev/null +++ b/src/com/android/exoplayer/MediaFormatUtil.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.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, + 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, + MediaFormat.NO_VALUE); + 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/src/com/android/exoplayer/MediaSoftwareCodecUtil.java b/src/com/android/exoplayer/MediaSoftwareCodecUtil.java new file mode 100644 index 00000000..cf74f106 --- /dev/null +++ b/src/com/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/src/com/android/exoplayer/text/SubtitleView.java b/src/com/android/exoplayer/text/SubtitleView.java new file mode 100644 index 00000000..e930ef2d --- /dev/null +++ b/src/com/android/exoplayer/text/SubtitleView.java @@ -0,0 +1,321 @@ +/* + * 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.google.android.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.util.Util; +import java.util.ArrayList; + +/** + * 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 (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/src/com/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java b/src/com/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java new file mode 100644 index 00000000..321e19da --- /dev/null +++ b/src/com/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.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.google.android.exoplayer2.ext.ffmpeg; + +import android.content.Context; +import android.content.pm.PackageManager; +import com.android.tv.common.SoftPreconditions; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; +import com.google.android.exoplayer2.util.MimeTypes; +import java.nio.ByteBuffer; + +/** + * Audio decoder which uses ffmpeg extension of ExoPlayer2. Since {@link FfmpegDecoder} is package + * private, expose the decoder via this class. Supported formats are AC3 and MP2. + */ +public class FfmpegAudioDecoder { + private static final int NUM_DECODER_BUFFERS = 1; + + // The largest AC3 sample size. This is bigger than the largest MP2 sample size (1729). + private static final int INITIAL_INPUT_BUFFER_SIZE = 2560; + private static boolean AVAILABLE; + + static { + AVAILABLE = + FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_AC3) + && FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_MPEG_L2); + } + + private FfmpegDecoder mDecoder; + private DecoderInputBuffer mInputBuffer; + private SimpleOutputBuffer mOutputBuffer; + private boolean mStarted; + + /** Return whether Ffmpeg based software audio decoder is available. */ + public static boolean isAvailable() { + return AVAILABLE; + } + + /** Creates an Ffmpeg based software audio decoder. */ + public FfmpegAudioDecoder(Context context) { + if (context.checkSelfPermission("android.permission.INTERNET") + == PackageManager.PERMISSION_GRANTED) { + throw new IllegalStateException("This code should run in an isolated process"); + } + } + + /** + * Decodes an audio sample. + * + * @param timeUs presentation timestamp of the sample + * @param sample data + */ + public void decode(long timeUs, byte[] sample) { + SoftPreconditions.checkState(AVAILABLE); + mInputBuffer.data.clear(); + mInputBuffer.data.put(sample); + mInputBuffer.data.flip(); + mInputBuffer.timeUs = timeUs; + mDecoder.decode(mInputBuffer, mOutputBuffer, !mStarted); + if (!mStarted) { + mStarted = true; + } + } + + /** Returns a decoded sample from decoder. */ + public ByteBuffer getDecodedSample() { + return mOutputBuffer.data; + } + + /** Returns the presentation time for the decoded sample. */ + public long getDecodedTimeUs() { + return mOutputBuffer.timeUs; + } + + /** + * Clear previous decode state if any. Prepares to decode samples of the specified encoding. + * This method should be called before using decode. + * + * @param mime audio encoding + */ + public void resetDecoderState(String mime) { + SoftPreconditions.checkState(AVAILABLE); + release(); + try { + mDecoder = + new FfmpegDecoder( + NUM_DECODER_BUFFERS, + NUM_DECODER_BUFFERS, + INITIAL_INPUT_BUFFER_SIZE, + mime, + null); + mStarted = false; + mInputBuffer = mDecoder.createInputBuffer(); + // Since native JNI requires direct buffer, we should allocate it by #allocateDirect. + mInputBuffer.data = ByteBuffer.allocateDirect(INITIAL_INPUT_BUFFER_SIZE); + mOutputBuffer = mDecoder.createOutputBuffer(); + } catch (FfmpegDecoderException e) { + // if AVAILABLE is {@code true}, this will not happen. + } + } + + /** Releases all the resource. */ + public void release() { + SoftPreconditions.checkState(AVAILABLE); + if (mDecoder != null) { + mDecoder.release(); + mInputBuffer = null; + mOutputBuffer = null; + mDecoder = null; + } + } +} diff --git a/src/com/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java b/src/com/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java new file mode 100644 index 00000000..a33d4020 --- /dev/null +++ b/src/com/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java @@ -0,0 +1,75 @@ +/* + * 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.google.android.exoplayer2.ext.ffmpeg; + +import com.google.android.exoplayer2.util.LibraryLoader; +import com.google.android.exoplayer2.util.MimeTypes; + +/** + * This class is based on com.google.android.exoplayer2.ext.ffmpeg.FfmpegLibrary from ExoPlayer2 in + * order to support mp2 decoder. Configures and queries the underlying native library. + */ +public final class FfmpegLibrary { + + private static final LibraryLoader LOADER = + new LibraryLoader("avutil", "avresample", "avcodec", "ffmpeg"); + + private FfmpegLibrary() {} + + /** + * Overrides the names of the FFmpeg native libraries. If an application wishes to call this + * method, it must do so before calling any other method defined by this class, and before + * instantiating a {@link FfmpegAudioRenderer} instance. + */ + public static void setLibraries(String... libraries) { + LOADER.setLibraries(libraries); + } + + /** Returns whether the underlying library is available, loading it if necessary. */ + public static boolean isAvailable() { + return LOADER.isAvailable(); + } + + /** Returns the version of the underlying library if available, or null otherwise. */ + public static String getVersion() { + return isAvailable() ? ffmpegGetVersion() : null; + } + + /** Returns whether the underlying library supports the specified MIME type. */ + public static boolean supportsFormat(String mimeType) { + if (!isAvailable()) { + return false; + } + String codecName = getCodecName(mimeType); + return codecName != null && ffmpegHasDecoder(codecName); + } + + /** Returns the name of the FFmpeg decoder that could be used to decode {@code mimeType}. */ + /* package */ static String getCodecName(String mimeType) { + switch (mimeType) { + case MimeTypes.AUDIO_MPEG_L2: + return "mp2"; + case MimeTypes.AUDIO_AC3: + return "ac3"; + default: + return null; + } + } + + private static native String ffmpegGetVersion(); + + private static native boolean ffmpegHasDecoder(String codecName); +} diff --git a/src/com/android/tv/TvSingletons.java b/src/com/android/tv/ApplicationSingletons.java index 80c74576..f9eaf58e 100644 --- a/src/com/android/tv/TvSingletons.java +++ b/src/com/android/tv/ApplicationSingletons.java @@ -16,41 +16,27 @@ package com.android.tv; -import android.content.Context; import com.android.tv.analytics.Analytics; import com.android.tv.analytics.Tracker; -import com.android.tv.common.BaseApplication; -import com.android.tv.common.BaseSingletons; -import com.android.tv.common.experiments.ExperimentLoader; +import com.android.tv.config.RemoteConfig; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.PreviewDataManager; import com.android.tv.data.ProgramDataManager; -import com.android.tv.data.epg.EpgFetcher; -import com.android.tv.data.epg.EpgReader; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.DvrStorageStatusManager; import com.android.tv.dvr.DvrWatchedPositionManager; import com.android.tv.dvr.recorder.RecordingScheduler; import com.android.tv.perf.PerformanceMonitor; -import com.android.tv.tuner.TunerInputController; -import com.android.tv.util.SetupUtils; +import com.android.tv.util.AccountHelper; import com.android.tv.util.TvInputManagerHelper; -import com.android.tv.util.account.AccountHelper; -import javax.inject.Provider; /** Interface with getters for application scoped singletons. */ -public interface TvSingletons extends BaseSingletons { - - /** Returns the @{@link TvSingletons} using the application context. */ - static TvSingletons getSingletons(Context context) { - return (TvSingletons) BaseApplication.getSingletons(context); - } +public interface ApplicationSingletons { Analytics getAnalytics(); - void handleInputCountChanged(); - ChannelDataManager getChannelDataManager(); /** @@ -71,6 +57,8 @@ public interface TvSingletons extends BaseSingletons { DvrDataManager getDvrDataManager(); + DvrStorageStatusManager getDvrStorageStatusManager(); + DvrScheduleManager getDvrScheduleManager(); DvrManager getDvrManager(); @@ -83,23 +71,15 @@ public interface TvSingletons extends BaseSingletons { Tracker getTracker(); + TvInputManagerHelper getTvInputManagerHelper(); + MainActivityWrapper getMainActivityWrapper(); AccountHelper getAccountHelper(); + RemoteConfig getRemoteConfig(); + boolean isRunningInMainProcess(); PerformanceMonitor getPerformanceMonitor(); - - TvInputManagerHelper getTvInputManagerHelper(); - - Provider<EpgReader> providesEpgReader(); - - EpgFetcher getEpgFetcher(); - - SetupUtils getSetupUtils(); - - TunerInputController getTunerInputController(); - - ExperimentLoader getExperimentLoader(); } diff --git a/src/com/android/tv/AudioManagerHelper.java b/src/com/android/tv/AudioManagerHelper.java index b0187617..f4bfdb9a 100644 --- a/src/com/android/tv/AudioManagerHelper.java +++ b/src/com/android/tv/AudioManagerHelper.java @@ -1,18 +1,3 @@ -/* - * 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; import android.app.Activity; @@ -61,33 +46,20 @@ class AudioManagerHelper implements AudioManager.OnAudioFocusChangeListener { if (mTvView.isPlaying()) { switch (mAudioFocusStatus) { case AudioManager.AUDIOFOCUS_GAIN: - if (mTvView.isTimeShiftAvailable()) { - mTvView.timeshiftPlay(); - } else { - mTvView.setStreamVolume(AUDIO_MAX_VOLUME); - } + mTvView.setStreamVolume(AUDIO_MAX_VOLUME); break; case AudioManager.AUDIOFOCUS_LOSS: - if (TvFeatures.PICTURE_IN_PICTURE.isEnabled(mActivity) + if (Features.PICTURE_IN_PICTURE.isEnabled(mActivity) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && mActivity.isInPictureInPictureMode()) { mActivity.finish(); break; } - // fall through case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - if (mTvView.isTimeShiftAvailable()) { - mTvView.timeshiftPause(); - } else { - mTvView.setStreamVolume(AUDIO_MIN_VOLUME); - } + mTvView.setStreamVolume(AUDIO_MIN_VOLUME); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: - if (mTvView.isTimeShiftAvailable()) { - mTvView.timeshiftPause(); - } else { - mTvView.setStreamVolume(AUDIO_DUCKING_VOLUME); - } + mTvView.setStreamVolume(AUDIO_DUCKING_VOLUME); break; } } diff --git a/src/com/android/tv/Features.java b/src/com/android/tv/Features.java new file mode 100644 index 00000000..3f8e56a8 --- /dev/null +++ b/src/com/android/tv/Features.java @@ -0,0 +1,193 @@ +/* + * 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; + +import static com.android.tv.common.feature.EngOnlyFeature.ENG_ONLY_FEATURE; +import static com.android.tv.common.feature.FeatureUtils.AND; +import static com.android.tv.common.feature.FeatureUtils.OFF; +import static com.android.tv.common.feature.FeatureUtils.ON; +import static com.android.tv.common.feature.FeatureUtils.OR; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.support.annotation.VisibleForTesting; +import android.util.Log; +import com.android.tv.common.feature.Feature; +import com.android.tv.common.feature.GServiceFeature; +import com.android.tv.common.feature.PropertyFeature; +import com.android.tv.experiments.Experiments; +import com.android.tv.util.LocationUtils; +import com.android.tv.util.PermissionUtils; +import com.android.tv.util.Utils; +import java.util.Locale; + +/** + * List of {@link Feature} for the Live TV App. + * + * <p>Remove the {@code Feature} once it is launched. + */ +public final class Features { + private static final String TAG = "Features"; + private static final boolean DEBUG = false; + + /** + * UI for opting in to analytics. + * + * <p>Do not turn this on until the splash screen asking existing users to opt-in is launched. + * See <a href="http://b/20228119">b/20228119</a> + */ + public static final Feature ANALYTICS_OPT_IN = ENG_ONLY_FEATURE; + + /** + * Analytics that include sensitive information such as channel or program identifiers. + * + * <p>See <a href="http://b/22062676">b/22062676</a> + */ + public static final Feature ANALYTICS_V2 = AND(ON, ANALYTICS_OPT_IN); + + public static final Feature EPG_SEARCH = + new PropertyFeature("feature_tv_use_epg_search", false); + + public static final Feature TUNER = + new Feature() { + @Override + public boolean isEnabled(Context context) { + + if (Utils.isDeveloper()) { + // we enable tuner for developers to test tuner in any platform. + return true; + } + + // This is special handling just for USB Tuner. + // It does not require any N API's but relies on a improvements in N for AC3 + // support + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; + } + }; + + /** 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 (Utils.isDeveloper()) { + // Network tuner will be enabled for developers. + return true; + } + return Locale.US + .getCountry() + .equalsIgnoreCase(LocationUtils.getCurrentCountry(context)); + } + }; + + private static final String GSERVICE_KEY_UNHIDE = "live_channels_unhide"; + /** A flag which indicates that LC app is unhidden even when there is no input. */ + public static final Feature UNHIDE = + OR( + new GServiceFeature(GSERVICE_KEY_UNHIDE, false), + new Feature() { + @Override + public boolean isEnabled(Context context) { + // If LC app runs as non-system app, we unhide the app. + return !PermissionUtils.hasAccessAllEpg(context); + } + }); + + public static final Feature PICTURE_IN_PICTURE = + new Feature() { + private Boolean mEnabled; + + @Override + public boolean isEnabled(Context context) { + if (mEnabled == null) { + mEnabled = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + && context.getPackageManager() + .hasSystemFeature( + PackageManager.FEATURE_PICTURE_IN_PICTURE); + } + return mEnabled; + } + }; + + /** 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; + } + }; + + /** Show postal code fragment before channel scan. */ + public static final Feature ENABLE_CLOUD_EPG_REGION = + new Feature() { + private final String[] SUPPORTED_REGIONS = {}; + + @Override + public boolean isEnabled(Context context) { + if (!Experiments.CLOUD_EPG.get()) { + if (DEBUG) Log.d(TAG, "Experiments.CLOUD_EPG is false"); + return false; + } + 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, "EPG flag false after country check"); + return false; + } + }; + + /** Enable a conflict dialog between currently watched channel and upcoming recording. */ + public static final Feature SHOW_UPCOMING_CONFLICT_DIALOG = OFF; + + /** Use input blacklist to disable partner's tuner input. */ + public static final Feature USE_PARTNER_INPUT_BLACKLIST = ON; + + /** Enable Dvb parsers and listeners. */ + public static final Feature ENABLE_FILE_DVB = OFF; + + @VisibleForTesting + public static final Feature TEST_FEATURE = new PropertyFeature("test_feature", false); + + private Features() {} +} diff --git a/src/com/android/tv/InputSessionManager.java b/src/com/android/tv/InputSessionManager.java index 416dbb68..709ed4a4 100644 --- a/src/com/android/tv/InputSessionManager.java +++ b/src/com/android/tv/InputSessionManager.java @@ -76,7 +76,7 @@ public class InputSessionManager { public InputSessionManager(Context context) { mContext = context.getApplicationContext(); - mInputManager = TvSingletons.getSingletons(context).getTvInputManagerHelper(); + mInputManager = TvApplication.getSingletons(context).getTvInputManagerHelper(); } /** diff --git a/src/com/android/tv/LauncherActivity.java b/src/com/android/tv/LauncherActivity.java index 3aca35a4..545d49b1 100644 --- a/src/com/android/tv/LauncherActivity.java +++ b/src/com/android/tv/LauncherActivity.java @@ -27,16 +27,15 @@ import android.util.Log; * An activity to launch a new activity. * * <p>In the case when {@link MainActivity} starts a new activity using {@link - * Activity#startActivity} or {@link Activity#startActivityForResult}, Live TV app is - * terminated if the new activity crashes. That's because the {@link android.app.ActivityManager} - * terminates the activity which is just below the crashed activity in the activity stack. To avoid - * this, we need to locate an additional activity between these activities in the activity stack. + * Activity#startActivity} or {@link Activity#startActivityForResult}, Live TV app is terminated if + * the new activity crashes. That's because the {@link android.app.ActivityManager} terminates the + * activity which is just below the crashed activity in the activity stack. To avoid this, we need + * to locate an additional activity between these activities in the activity stack. */ public class LauncherActivity extends Activity { private static final String TAG = "LauncherActivity"; - public static final String ERROR_MESSAGE = - "com.android.tv.LauncherActivity.ErrorMessage"; + public static final String ERROR_MESSAGE = "com.android.tv.LauncherActivity.ErrorMessage"; private static final int REQUEST_CODE_DEFAULT = 0; private static final int REQUEST_START_ACTIVITY = 100; @@ -53,6 +52,22 @@ public class LauncherActivity extends Activity { createIntent(baseActivity, intentToLaunch, false), REQUEST_CODE_DEFAULT); } + /** + * Starts an activity by calling {@link Activity#startActivityForResult}. + * + * <p>Note: {@code requestCode} should not be 0. The value is reserved for internal use. + */ + public static void startActivityForResultSafe( + Activity baseActivity, Intent intentToLaunch, int requestCode) { + if (requestCode == REQUEST_CODE_DEFAULT) { + throw new IllegalArgumentException("requestCode should not be 0."); + } + // To avoid the app termination when the new activity crashes, LauncherActivity should be + // started by calling startActivityForResult(). + baseActivity.startActivityForResult( + createIntent(baseActivity, intentToLaunch, true), requestCode); + } + private static Intent createIntent( Context context, Intent intentToLaunch, boolean requestResult) { Intent intent = new Intent(context, LauncherActivity.class); diff --git a/src/com/android/tv/MainActivity.java b/src/com/android/tv/MainActivity.java index 22ee9321..427d562a 100644 --- a/src/com/android/tv/MainActivity.java +++ b/src/com/android/tv/MainActivity.java @@ -17,7 +17,6 @@ package com.android.tv; import android.app.Activity; -import android.app.PendingIntent; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; import android.content.ComponentName; @@ -67,18 +66,13 @@ import com.android.tv.analytics.SendChannelStatusRunnable; import com.android.tv.analytics.SendConfigInfoRunnable; import com.android.tv.analytics.Tracker; import com.android.tv.common.BuildConfig; -import com.android.tv.common.CommonPreferences; import com.android.tv.common.MemoryManageable; import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonUtils; import com.android.tv.common.TvContentRatingCache; import com.android.tv.common.WeakHandler; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.common.ui.setup.OnActionClickListener; -import com.android.tv.common.util.CommonUtils; -import com.android.tv.common.util.Debug; -import com.android.tv.common.util.DurationTimer; -import com.android.tv.common.util.PermissionUtils; -import com.android.tv.common.util.SystemProperties; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.OnCurrentProgramUpdatedListener; @@ -86,6 +80,7 @@ import com.android.tv.data.Program; import com.android.tv.data.ProgramDataManager; import com.android.tv.data.StreamInfo; import com.android.tv.data.WatchedHistoryManager; +import com.android.tv.data.epg.EpgFetcher; import com.android.tv.dialog.HalfSizedDialogFragment; import com.android.tv.dialog.PinDialogFragment; import com.android.tv.dialog.PinDialogFragment.OnPinCheckedListener; @@ -95,17 +90,21 @@ import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.recorder.ConflictChecker; import com.android.tv.dvr.ui.DvrStopRecordingFragment; import com.android.tv.dvr.ui.DvrUiHelper; - import com.android.tv.menu.Menu; import com.android.tv.onboarding.OnboardingActivity; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.parental.ParentalControlSettings; import com.android.tv.perf.EventNames; import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.StubPerformanceMonitor; import com.android.tv.perf.TimerEvent; import com.android.tv.recommendation.ChannelPreviewUpdater; import com.android.tv.recommendation.NotificationService; import com.android.tv.search.ProgramGuideSearchFragment; +import com.android.tv.tuner.TunerInputController; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.setup.TunerSetupActivity; +import com.android.tv.tuner.tvinput.TunerTvInputService; import com.android.tv.ui.ChannelBannerView; import com.android.tv.ui.InputBannerView; import com.android.tv.ui.KeypadChannelSwitchView; @@ -125,16 +124,19 @@ import com.android.tv.ui.sidepanel.SettingsFragment; import com.android.tv.ui.sidepanel.SideFragment; import com.android.tv.ui.sidepanel.parentalcontrols.ParentalControlsFragment; import com.android.tv.util.CaptionSettings; +import com.android.tv.util.Debug; +import com.android.tv.util.DurationTimer; import com.android.tv.util.ImageCache; import com.android.tv.util.OnboardingUtils; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.RecurringRunner; import com.android.tv.util.SetupUtils; +import com.android.tv.util.SystemProperties; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.TvSettings; import com.android.tv.util.TvTrackInfoUtils; import com.android.tv.util.Utils; import com.android.tv.util.ViewCache; -import com.android.tv.util.account.AccountHelper; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayDeque; @@ -146,7 +148,6 @@ import java.util.Set; import java.util.concurrent.TimeUnit; /** The main activity for the Live TV app. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class MainActivity extends Activity implements OnActionClickListener, OnPinCheckedListener { private static final String TAG = "MainActivity"; private static final boolean DEBUG = false; @@ -175,7 +176,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP // Tracker screen names. public static final String SCREEN_NAME = "Main"; - private static final String SCREEN_PIP = "PIP"; private static final String SCREEN_BEHIND_NAME = "Behind"; private static final float REFRESH_RATE_EPSILON = 0.01f; @@ -205,7 +205,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP } private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1; - private static final int REQUEST_CODE_NOW_PLAYING = 2; private static final String KEY_INIT_CHANNEL_ID = "com.android.tv.init_channel_id"; @@ -240,7 +239,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP private final DurationTimer mTuneDurationTimer = new DurationTimer(); private DvrManager mDvrManager; private ConflictChecker mDvrConflictChecker; - private SetupUtils mSetupUtils; private View mContentView; private TunableTvView mTvView; @@ -273,7 +271,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP private boolean mOtherActivityLaunched; private PerformanceMonitor mPerformanceMonitor; - private boolean mIsInPIPMode; private boolean mIsFilmModeSet; private float mDefaultRefreshRate; @@ -310,9 +307,10 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP case Intent.ACTION_SCREEN_OFF: if (DEBUG) Log.d(TAG, "Received ACTION_SCREEN_OFF"); // We need to stop TvView, when the screen is turned off. If not and TIS - // uses MediaPlayer, a device may not go to the sleep mode and audio - // can be heard, because MediaPlayer keeps playing media by its wake - // lock. + // uses + // MediaPlayer, a device may not go to the sleep mode and audio can be + // heard, + // because MediaPlayer keeps playing media by its wake lock. mScreenOffIntentReceived = true; markCurrentChannelDuringScreenOff(); stopAll(true); @@ -321,9 +319,10 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP if (DEBUG) Log.d(TAG, "Received ACTION_SCREEN_ON"); if (!mActivityResumed && mVisibleBehind) { // ACTION_SCREEN_ON is usually called after onResume. But, if media - // is played under launcher with requestVisibleBehind(true), - // onResume will not be called. In this case, we need to resume - // TvView explicitly. + // is + // played under launcher with requestVisibleBehind(true), onResume + // will + // not be called. In this case, we need to resume TvView explicitly. resumeTvIfNeeded(); } break; @@ -340,7 +339,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP tune(true); } break; - default: // fall out } } }; @@ -369,7 +367,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP public void onLoadFinished() { Debug.getTimer(Debug.TAG_START_UP_TIMER) .log("MainActivity.mChannelTunerListener.onLoadFinished"); - mSetupUtils.markNewChannelsBrowsable(); + SetupUtils.getInstance(MainActivity.this).markNewChannelsBrowsable(); if (mActivityResumed) { resumeTvIfNeeded(); } @@ -407,16 +405,13 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP new TvInputCallback() { @Override public void onInputAdded(String inputId) { - if (TvFeatures.TUNER.isEnabled(MainActivity.this) + if (Features.TUNER.isEnabled(MainActivity.this) && mTunerInputId.equals(inputId) - && CommonPreferences.shouldShowSetupActivity(MainActivity.this)) { - Intent intent = - TvSingletons - .getSingletons(MainActivity.this) - .getTunerSetupIntent(MainActivity.this); + && TunerPreferences.shouldShowSetupActivity(MainActivity.this)) { + Intent intent = TunerSetupActivity.createSetupActivity(MainActivity.this); startActivity(intent); - CommonPreferences.setShouldShowSetupActivity(MainActivity.this, false); - mSetupUtils.markAsKnownInput(mTunerInputId); + TunerPreferences.setShouldShowSetupActivity(MainActivity.this, false); + SetupUtils.getInstance(MainActivity.this).markAsKnownInput(mTunerInputId); } } }; @@ -432,9 +427,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP @Override protected void onCreate(Bundle savedInstanceState) { - TvSingletons tvSingletons = TvSingletons.getSingletons(this); - mPerformanceMonitor = tvSingletons.getPerformanceMonitor(); - TimerEvent timer = mPerformanceMonitor.startTimer(); + TimerEvent timer = StubPerformanceMonitor.startBootstrapTimer(); DurationTimer startUpDebugTimer = Debug.getTimer(Debug.TAG_START_UP_TIMER); if (!startUpDebugTimer.isStarted() || startUpDebugTimer.getDuration() > START_UP_TIMER_RESET_THRESHOLD_MS) { @@ -443,18 +436,16 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP startUpDebugTimer.start(); } startUpDebugTimer.log("MainActivity.onCreate"); - if (DEBUG) { - Log.d(TAG, "onCreate()"); - } - Starter.start(this); + if (DEBUG) Log.d(TAG, "onCreate()"); + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); - if (!tvSingletons.getTvInputManagerHelper().hasTvInputManager()) { + ApplicationSingletons applicationSingletons = TvApplication.getSingletons(this); + if (!applicationSingletons.getTvInputManagerHelper().hasTvInputManager()) { Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); finishAndRemoveTask(); return; } - mPerformanceMonitor = tvSingletons.getPerformanceMonitor(); - mSetupUtils = tvSingletons.getSetupUtils(); + mPerformanceMonitor = applicationSingletons.getPerformanceMonitor(); TvApplication tvApplication = (TvApplication) getApplication(); mChannelDataManager = tvApplication.getChannelDataManager(); @@ -469,7 +460,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP if ((OnboardingUtils.isFirstRunWithCurrentVersion(this) || channelLoadedAndNoChannelAvailable) && !tuneToPassthroughInput - && !CommonUtils.isRunningInTest()) { + && !TvCommonUtils.isRunningInTest()) { startOnboardingActivity(); return; } @@ -504,9 +495,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP }); long channelId = Utils.getLastWatchedChannelId(this); String inputId = Utils.getLastWatchedTunerInputId(this); - if (!isPassthroughInput - && inputId != null - && channelId != Channel.INVALID_ID) { + if (!isPassthroughInput && inputId != null && channelId != Channel.INVALID_ID) { mTvView.warmUpInput(inputId, TvContract.buildChannelUri(channelId)); } @@ -515,10 +504,10 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP Toast.makeText(this, "Using Strict Mode for eng builds", Toast.LENGTH_SHORT).show(); } mTracker = tvApplication.getTracker(); - if (TvFeatures.TUNER.isEnabled(this)) { + if (Features.TUNER.isEnabled(this)) { mTvInputManagerHelper.addCallback(mTvInputCallback); } - mTunerInputId = tvSingletons.getEmbeddedTunerInputId(); + mTunerInputId = TunerTvInputService.getInputId(this); mProgramDataManager.addOnCurrentProgramUpdatedListener( Channel.INVALID_ID, mOnCurrentProgramUpdatedListener); mProgramDataManager.setPrefetchEnabled(true); @@ -646,10 +635,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP mSearchFragment); mAudioManagerHelper = new AudioManagerHelper(this, mTvView); - Intent nowPlayingIntent = new Intent(this, MainActivity.class); - PendingIntent pendingIntent = - PendingIntent.getActivity(this, REQUEST_CODE_NOW_PLAYING, nowPlayingIntent, 0); - mMediaSessionWrapper = new MediaSessionWrapper(this, pendingIntent); + mMediaSessionWrapper = new MediaSessionWrapper(this); mTvViewUiManager.restoreDisplayMode(false); if (!handleIntent(getIntent())) { @@ -673,7 +659,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP // To avoid not updating Rating systems when changing language. mTvInputManagerHelper.getContentRatingsManager().update(); if (CommonFeatures.DVR.isEnabled(this) - && TvFeatures.SHOW_UPCOMING_CONFLICT_DIALOG.isEnabled(this)) { + && Features.SHOW_UPCOMING_CONFLICT_DIALOG.isEnabled(this)) { mDvrConflictChecker = new ConflictChecker(this); } initForTest(); @@ -752,9 +738,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP @Override protected void onNewIntent(Intent intent) { - if (DEBUG) { - Log.d(TAG, "onNewIntent(): " + intent); - } + if (DEBUG) Log.d(TAG, "onNewIntent(): " + intent); if (mOverlayManager == null) { // It's called before onCreate. The intent will be handled at onCreate. b/30725058 return; @@ -770,9 +754,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP @Override protected void onStart() { TimerEvent timer = mPerformanceMonitor.startTimer(); - if (DEBUG) { - Log.d(TAG, "onStart()"); - } + if (DEBUG) Log.d(TAG, "onStart()"); super.onStart(); mScreenOffIntentReceived = false; mActivityStarted = true; @@ -787,9 +769,9 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP notificationIntent.setAction(NotificationService.ACTION_SHOW_RECOMMENDATION); startService(notificationIntent); } - TvSingletons singletons = TvSingletons.getSingletons(this); - singletons.getTunerInputController().executeNetworkTunerDiscoveryAsyncTask(this); - singletons.getEpgFetcher().fetchImmediatelyIfNeeded(); + TunerInputController.executeNetworkTunerDiscoveryAsyncTask(this); + + EpgFetcher.getInstance(this).fetchImmediatelyIfNeeded(); mPerformanceMonitor.stopTimer(timer, EventNames.MAIN_ACTIVITY_ONSTART); } @@ -799,7 +781,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP Debug.getTimer(Debug.TAG_START_UP_TIMER).log("MainActivity.onResume start"); if (DEBUG) Log.d(TAG, "onResume()"); super.onResume(); - mIsInPIPMode = false; if (!PermissionUtils.hasAccessAllEpg(this) && checkSelfPermission(PERMISSION_READ_TV_LISTINGS) != PackageManager.PERMISSION_GRANTED) { @@ -837,7 +818,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP } if (mChannelTuner.areAllChannelsLoaded()) { - mSetupUtils.markNewChannelsBrowsable(); + SetupUtils.getInstance(this).markNewChannelsBrowsable(); resumeTvIfNeeded(); } mOverlayManager.showMenuWithTimeShiftPauseIfNeeded(); @@ -849,11 +830,13 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP mInputToSetUp = null; } else if (mShowProgramGuide) { mShowProgramGuide = false; - // This will delay the start of the animation until after the Live Channel app is - // shown. Without this the animation is completed before it is actually visible on - // the screen. mHandler.post( new Runnable() { + // This will delay the start of the animation until after the Live Channel + // app is + // shown. Without this the animation is completed before it is actually + // visible on + // the screen. @Override public void run() { mOverlayManager.showProgramGuide(); @@ -861,12 +844,16 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP }); } else if (mShowSelectInputView) { mShowSelectInputView = false; - // mShowSelectInputView is true when the activity is started/resumed because the - // TV_INPUT button was pressed in a different app. This will delay the start of - // the animation until after the Live Channel app is shown. Without this the - // animation is completed before it is actually visible on the screen. mHandler.post( new Runnable() { + // mShowSelectInputView is true when the activity is started/resumed because + // the + // TV_INPUT button was pressed in a different app. + // This will delay the start of the animation until after the Live Channel + // app is + // shown. Without this the animation is completed before it is actually + // visible on + // the screen. @Override public void run() { mOverlayManager.showSelectInputView(); @@ -894,17 +881,12 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP mShowLockedChannelsTemporarily = false; mShouldTuneToTunerChannel = false; if (!mVisibleBehind) { - if (mIsInPIPMode) { - mTracker.sendScreenView(SCREEN_PIP); - } else { - mTracker.sendScreenView(""); - mAudioManagerHelper.abandonAudioFocus(); - mMediaSessionWrapper.setPlaybackState(false); - } + mAudioManagerHelper.abandonAudioFocus(); + mMediaSessionWrapper.setPlaybackState(false); + mTracker.sendScreenView(""); } else { mTracker.sendScreenView(SCREEN_BEHIND_NAME); } - TvSingletons.getSingletons(this).getExperimentLoader().asyncRefreshExperiments(this); super.onPause(); } @@ -942,11 +924,10 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP mOverlayManager .getSideFragmentManager() .show(new ParentalControlsFragment(), false); - // fall through. + // Pass through. case PinDialogFragment.PIN_DIALOG_TYPE_NEW_PIN: mOverlayManager.getSideFragmentManager().showSidePanel(true); break; - default: // fall out } } else if (type == PinDialogFragment.PIN_DIALOG_TYPE_ENTER_PIN) { mOverlayManager.getSideFragmentManager().hideAll(false); @@ -1094,7 +1075,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP * @param calledByPopup If true, startSetupActivity is invoked from the setup fragment. */ public void startSetupActivity(TvInputInfo input, boolean calledByPopup) { - Intent intent = CommonUtils.createSetupIntent(input); + Intent intent = TvCommonUtils.createSetupIntent(input); if (intent == null) { Toast.makeText(this, R.string.msg_no_setup_activity, Toast.LENGTH_SHORT).show(); return; @@ -1238,6 +1219,15 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP LauncherActivity.startActivitySafe(this, intent); } + /** + * Call {@link Activity#startActivityForResult} in a safe way. + * + * @see LauncherActivity + */ + private void startActivityForResultSafe(Intent intent, int requestCode) { + LauncherActivity.startActivityForResultSafe(this, intent, requestCode); + } + /** Show settings fragment. */ public void showSettingsFragment() { if (!mChannelTuner.areAllChannelsLoaded()) { @@ -1330,39 +1320,31 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - switch (requestCode) { - case REQUEST_CODE_START_SETUP_ACTIVITY: - if (resultCode == RESULT_OK) { - int count = mChannelDataManager.getChannelCountForInput(mInputIdUnderSetup); - String text; - if (count > 0) { - text = - getResources() - .getQuantityString( - R.plurals.msg_channel_added, count, count); - } else { - text = getString(R.string.msg_no_channel_added); - } - Toast.makeText(MainActivity.this, text, Toast.LENGTH_SHORT).show(); - mInputIdUnderSetup = null; - if (mChannelTuner.getCurrentChannel() == null) { - mChannelTuner.moveToAdjacentBrowsableChannel(true); - } - if (mTunePending) { - tune(true); - } + if (requestCode == REQUEST_CODE_START_SETUP_ACTIVITY) { + if (resultCode == RESULT_OK) { + int count = mChannelDataManager.getChannelCountForInput(mInputIdUnderSetup); + String text; + if (count > 0) { + text = + getResources() + .getQuantityString(R.plurals.msg_channel_added, count, count); } else { - mInputIdUnderSetup = null; + text = getString(R.string.msg_no_channel_added); } - if (!mIsSetupActivityCalledByPopup) { - mOverlayManager.getSideFragmentManager().showSidePanel(false); + Toast.makeText(MainActivity.this, text, Toast.LENGTH_SHORT).show(); + mInputIdUnderSetup = null; + if (mChannelTuner.getCurrentChannel() == null) { + mChannelTuner.moveToAdjacentBrowsableChannel(true); } - break; - case REQUEST_CODE_NOW_PLAYING: - // nothing needs to be done. onResume will restore everything. - break; - default: - // do nothing + if (mTunePending) { + tune(true); + } + } else { + mInputIdUnderSetup = null; + } + if (!mIsSetupActivityCalledByPopup) { + mOverlayManager.getSideFragmentManager().showSidePanel(false); + } } if (data != null) { String errorMessage = data.getStringExtra(LauncherActivity.ERROR_MESSAGE); @@ -1554,7 +1536,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP mAudioManagerHelper.abandonAudioFocus(); mMediaSessionWrapper.setPlaybackState(false); } - TvSingletons.getSingletons(this) + TvApplication.getSingletons(this) .getMainActivityWrapper() .notifyCurrentChannelChange(this, null); mChannelTuner.resetCurrentChannel(); @@ -1601,8 +1583,8 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP finish(); return; } - - if (mSetupUtils.isFirstTune()) { + SetupUtils setupUtils = SetupUtils.getInstance(this); + if (setupUtils.isFirstTune()) { if (!mChannelTuner.areAllChannelsLoaded()) { // tune() will be called, once all channels are loaded. stopTv("tune()", false); @@ -1631,9 +1613,9 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP } return; } - if (!CommonUtils.isRunningInTest() + if (!TvCommonUtils.isRunningInTest() && mShowNewSourcesFragment - && mSetupUtils.hasUnrecognizedInput(mTvInputManagerHelper)) { + && setupUtils.hasUnrecognizedInput(mTvInputManagerHelper)) { // Show new channel sources fragment. runAfterAttachedToWindow( new Runnable() { @@ -1649,7 +1631,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP } }); } - mSetupUtils.onTuned(); + setupUtils.onTuned(); if (mTuneParams != null) { Long initChannelId = mTuneParams.getLong(KEY_INIT_CHANNEL_ID); if (initChannelId == channel.getId()) { @@ -1689,7 +1671,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP addToRecentChannels(channel.getId()); } Utils.setLastWatchedChannel(this, channel); - TvSingletons.getSingletons(this) + TvApplication.getSingletons(this) .getMainActivityWrapper() .notifyCurrentChannelChange(this, channel); } @@ -1989,7 +1971,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP } if (mTvInputManagerHelper != null) { mTvInputManagerHelper.clearTvInputLabels(); - if (TvFeatures.TUNER.isEnabled(this)) { + if (Features.TUNER.isEnabled(this)) { mTvInputManagerHelper.removeCallback(mTvInputCallback); } } @@ -2010,7 +1992,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP return false; case KEY_EVENT_HANDLER_RESULT_PASSTHROUGH: default: - // fall through + // pass through } if (mSearchFragment.isVisible()) { return super.onKeyDown(keyCode, event); @@ -2048,7 +2030,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP mTracker.sendChannelDown(); } return true; - default: // fall out } } return super.onKeyDown(keyCode, event); @@ -2089,7 +2070,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP return false; case KEY_EVENT_HANDLER_RESULT_PASSTHROUGH: default: - // fall through + // pass through } if (mSearchFragment.isVisible()) { if (keyCode == KeyEvent.KEYCODE_BACK) { @@ -2119,7 +2100,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP case KeyEvent.KEYCODE_MENU: showSettingsFragment(); return true; - default: // fall out } } else { if (KeypadChannelSwitchView.isChannelNumberKey(keyCode)) { @@ -2187,74 +2167,82 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP if (!SystemProperties.USE_DEBUG_KEYS.getValue()) { break; } - // fall through. + // Pass through. case KeyEvent.KEYCODE_CAPTIONS: - mOverlayManager.getSideFragmentManager().show(new ClosedCaptionFragment()); - return true; + { + mOverlayManager.getSideFragmentManager().show(new ClosedCaptionFragment()); + return true; + } case KeyEvent.KEYCODE_A: if (!SystemProperties.USE_DEBUG_KEYS.getValue()) { break; } - // fall through. + // Pass through. case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK: - mOverlayManager.getSideFragmentManager().show(new MultiAudioFragment()); - return true; + { + mOverlayManager.getSideFragmentManager().show(new MultiAudioFragment()); + return true; + } case KeyEvent.KEYCODE_INFO: - mOverlayManager.showBanner(); - return true; + { + mOverlayManager.showBanner(); + return true; + } case KeyEvent.KEYCODE_MEDIA_RECORD: case KeyEvent.KEYCODE_V: - Channel currentChannel = getCurrentChannel(); - if (currentChannel != null && mDvrManager != null) { - boolean isRecording = - mDvrManager.getCurrentRecording(currentChannel.getId()) != null; - if (!isRecording) { - if (!mDvrManager.isChannelRecordable(currentChannel)) { - Toast.makeText( - this, - R.string.dvr_msg_cannot_record_program, - Toast.LENGTH_SHORT) - .show(); + { + Channel currentChannel = getCurrentChannel(); + if (currentChannel != null && mDvrManager != null) { + boolean isRecording = + mDvrManager.getCurrentRecording(currentChannel.getId()) != null; + if (!isRecording) { + if (!mDvrManager.isChannelRecordable(currentChannel)) { + Toast.makeText( + this, + R.string.dvr_msg_cannot_record_program, + Toast.LENGTH_SHORT) + .show(); + } else { + Program program = + mProgramDataManager.getCurrentProgram( + currentChannel.getId()); + DvrUiHelper.checkStorageStatusAndShowErrorMessage( + this, + currentChannel.getInputId(), + new Runnable() { + @Override + public void run() { + DvrUiHelper.requestRecordingCurrentProgram( + MainActivity.this, + currentChannel, + program, + false); + } + }); + } } else { - Program program = - mProgramDataManager.getCurrentProgram( - currentChannel.getId()); - DvrUiHelper.checkStorageStatusAndShowErrorMessage( + DvrUiHelper.showStopRecordingDialog( this, - currentChannel.getInputId(), - new Runnable() { + currentChannel.getId(), + DvrStopRecordingFragment.REASON_USER_STOP, + new HalfSizedDialogFragment.OnActionClickListener() { @Override - public void run() { - DvrUiHelper.requestRecordingCurrentProgram( - MainActivity.this, - currentChannel, - program, - false); + public void onActionClick(long actionId) { + if (actionId + == DvrStopRecordingFragment.ACTION_STOP) { + ScheduledRecording currentRecording = + mDvrManager.getCurrentRecording( + currentChannel.getId()); + if (currentRecording != null) { + mDvrManager.stopRecording(currentRecording); + } + } } }); } - } else { - DvrUiHelper.showStopRecordingDialog( - this, - currentChannel.getId(), - DvrStopRecordingFragment.REASON_USER_STOP, - new HalfSizedDialogFragment.OnActionClickListener() { - @Override - public void onActionClick(long actionId) { - if (actionId == DvrStopRecordingFragment.ACTION_STOP) { - ScheduledRecording currentRecording = - mDvrManager.getCurrentRecording( - currentChannel.getId()); - if (currentRecording != null) { - mDvrManager.stopRecording(currentRecording); - } - } - } - }); } + return true; } - return true; - default: // fall out } } if (keyCode == KeyEvent.KEYCODE_WINDOW) { @@ -2292,7 +2280,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP case KeyEvent.KEYCODE_D: mOverlayManager.getSideFragmentManager().show(new DeveloperOptionFragment()); return true; - default: // fall out } } return super.onKeyUp(keyCode, event); @@ -2327,19 +2314,14 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP // We need to hide overlay first, before moving the activity to PIP. If not, UI will // be shown during PIP stack resizing, because UI and its animation is stuck during // PIP resizing. - mIsInPIPMode = true; - if (mOverlayManager.isOverlayOpened()) { - mOverlayManager.hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_WITHOUT_ANIMATION); - mHandler.post( - new Runnable() { - @Override - public void run() { - MainActivity.super.enterPictureInPictureMode(); - } - }); - } else { - MainActivity.super.enterPictureInPictureMode(); - } + mOverlayManager.hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_WITHOUT_ANIMATION); + mHandler.post( + new Runnable() { + @Override + public void run() { + MainActivity.super.enterPictureInPictureMode(); + } + }); } @Override @@ -2421,7 +2403,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP } else if (channel.equals(mTvView.getCurrentChannel())) { mOverlayManager.updateChannelBannerAndShowIfNeeded( TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_TUNE); - } else if (channel.equals(mChannelTuner.getCurrentChannel())) { + } else if (channel == mChannelTuner.getCurrentChannel()) { // Channel banner is already updated in moveToAdjacentChannel tune(false); } else if (mChannelTuner.moveToChannel(channel)) { @@ -2551,8 +2533,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP private void updateAvailabilityToast() { if (mTvView.isVideoAvailable() - || !Objects.equals( - mTvView.getCurrentChannel(), mChannelTuner.getCurrentChannel())) { + || mTvView.getCurrentChannel() != mChannelTuner.getCurrentChannel()) { return; } @@ -2616,7 +2597,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP // Initialize TV app for test. The setup process should be finished before the Live TV app is // started. We only enable all the channels here. private void initForTest() { - if (!CommonUtils.isRunningInTest()) { + if (!TvCommonUtils.isRunningInTest()) { return; } @@ -2688,7 +2669,6 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP sendMessageDelayed(Message.obtain(msg), getDelay(startTime)); mainActivity.moveToAdjacentChannel(true, true); break; - default: // fall out } } @@ -2735,7 +2715,7 @@ public class MainActivity extends Activity implements OnActionClickListener, OnP if (info.isVideoAvailable() && mTuneDurationTimer.isRunning()) { mTracker.sendChannelTuneTime(info.getCurrentChannel(), mTuneDurationTimer.reset()); } - if (info.isVideoOrAudioAvailable() && mChannel.equals(getCurrentChannel())) { + if (info.isVideoOrAudioAvailable() && mChannel == getCurrentChannel()) { mOverlayManager.updateChannelBannerAndShowIfNeeded( TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_UPDATE_STREAM_INFO); } diff --git a/src/com/android/tv/MediaSessionWrapper.java b/src/com/android/tv/MediaSessionWrapper.java index b3472ba5..2cc898c3 100644 --- a/src/com/android/tv/MediaSessionWrapper.java +++ b/src/com/android/tv/MediaSessionWrapper.java @@ -16,7 +16,6 @@ package com.android.tv; -import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; @@ -29,7 +28,6 @@ import android.media.tv.TvInputInfo; import android.os.AsyncTask; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import com.android.tv.data.Channel; import com.android.tv.data.Program; @@ -42,16 +40,14 @@ import com.android.tv.util.Utils; */ class MediaSessionWrapper { private static final String MEDIA_SESSION_TAG = "com.android.tv.mediasession"; - - private static final PlaybackState MEDIA_SESSION_STATE_PLAYING = + private static PlaybackState MEDIA_SESSION_STATE_PLAYING = new PlaybackState.Builder() .setState( PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN, 1.0f) .build(); - - private static final PlaybackState MEDIA_SESSION_STATE_STOPPED = + private static PlaybackState MEDIA_SESSION_STATE_STOPPED = new PlaybackState.Builder() .setState( PlaybackState.STATE_STOPPED, @@ -64,7 +60,7 @@ class MediaSessionWrapper { private int mNowPlayingCardWidth; private int mNowPlayingCardHeight; - MediaSessionWrapper(Context context, PendingIntent pendingIntent) { + MediaSessionWrapper(Context context) { mContext = context; mMediaSession = new MediaSession(context, MEDIA_SESSION_TAG); mMediaSession.setCallback( @@ -78,7 +74,6 @@ class MediaSessionWrapper { mMediaSession.setFlags( MediaSession.FLAG_HANDLES_MEDIA_BUTTONS | MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); - mMediaSession.setSessionActivity(pendingIntent); mNowPlayingCardWidth = mContext.getResources().getDimensionPixelSize(R.dimen.notif_card_img_max_width); mNowPlayingCardHeight = @@ -156,7 +151,7 @@ class MediaSessionWrapper { private String getChannelName(Channel channel) { if (channel.isPassthrough()) { TvInputInfo input = - TvSingletons.getSingletons(mContext) + TvApplication.getSingletons(mContext) .getTvInputManagerHelper() .getTvInputInfo(channel.getInputId()); return Utils.loadLabel(mContext, input); @@ -218,11 +213,6 @@ class MediaSessionWrapper { }.execute(); } - @VisibleForTesting - MediaSession getMediaSession() { - return mMediaSession; - } - private static class ProgramPosterArtCallback extends ImageLoader.ImageLoaderCallback<MediaSessionWrapper> { private final Channel mChannel; diff --git a/src/com/android/tv/SetupPassthroughActivity.java b/src/com/android/tv/SetupPassthroughActivity.java index 40d38118..d1158682 100644 --- a/src/com/android/tv/SetupPassthroughActivity.java +++ b/src/com/android/tv/SetupPassthroughActivity.java @@ -26,14 +26,12 @@ import android.os.Handler; import android.os.Looper; import android.support.annotation.MainThread; import android.util.Log; -import com.android.tv.common.CommonConstants; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.experiments.Experiments; +import com.android.tv.common.TvCommonConstants; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.ChannelDataManager.Listener; import com.android.tv.data.epg.EpgFetcher; -import com.android.tv.data.epg.EpgInputWhiteList; - +import com.android.tv.experiments.Experiments; import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; @@ -55,47 +53,35 @@ public class SetupPassthroughActivity extends Activity { private TvInputInfo mTvInputInfo; private Intent mActivityAfterCompletion; private boolean mEpgFetcherDuringScan; - private EpgInputWhiteList mEpgInputWhiteList; @Override public void onCreate(Bundle savedInstanceState) { if (DEBUG) Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); - TvSingletons tvSingletons = TvSingletons.getSingletons(this); - TvInputManagerHelper inputManager = tvSingletons.getTvInputManagerHelper(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(this); + TvInputManagerHelper inputManager = appSingletons.getTvInputManagerHelper(); Intent intent = getIntent(); - String inputId = intent.getStringExtra(CommonConstants.EXTRA_INPUT_ID); + String inputId = intent.getStringExtra(TvCommonConstants.EXTRA_INPUT_ID); mTvInputInfo = inputManager.getTvInputInfo(inputId); - mEpgInputWhiteList = new EpgInputWhiteList(tvSingletons.getRemoteConfig()); mActivityAfterCompletion = - intent.getParcelableExtra(CommonConstants.EXTRA_ACTIVITY_AFTER_COMPLETION); + intent.getParcelableExtra(TvCommonConstants.EXTRA_ACTIVITY_AFTER_COMPLETION); boolean needToFetchEpg = - mTvInputInfo != null - && Utils.isInternalTvInput(this, mTvInputInfo.getId()) - && Experiments.CLOUD_EPG.get(); + Utils.isInternalTvInput(this, mTvInputInfo.getId()) && Experiments.CLOUD_EPG.get(); if (needToFetchEpg) { // In case when the activity is restored, this flag should be restored as well. mEpgFetcherDuringScan = true; } if (savedInstanceState == null) { - SoftPreconditions.checkArgument( - CommonConstants.INTENT_ACTION_INPUT_SETUP.equals(intent.getAction()), - TAG, - "Unsupported action %s", - intent.getAction()); + SoftPreconditions.checkState( + intent.getAction().equals(TvCommonConstants.INTENT_ACTION_INPUT_SETUP)); if (DEBUG) Log.d(TAG, "TvInputId " + inputId + " / TvInputInfo " + mTvInputInfo); if (mTvInputInfo == null) { Log.w(TAG, "There is no input with the ID " + inputId + "."); finish(); return; } - if (intent.getExtras() == null) { - Log.w(TAG, "There is no extra info in the intent"); - finish(); - return; - } Intent setupIntent = - intent.getExtras().getParcelable(CommonConstants.EXTRA_SETUP_INTENT); + intent.getExtras().getParcelable(TvCommonConstants.EXTRA_SETUP_INTENT); if (DEBUG) Log.d(TAG, "Setup activity launch intent: " + setupIntent); if (setupIntent == null) { Log.w(TAG, "The input (" + mTvInputInfo.getId() + ") doesn't have setup."); @@ -107,7 +93,7 @@ public class SetupPassthroughActivity extends Activity { // If EXTRA_SETUP_INTENT is not removed, an infinite recursion happens during // setupIntent.putExtras(intent.getExtras()). Bundle extras = intent.getExtras(); - extras.remove(CommonConstants.EXTRA_SETUP_INTENT); + extras.remove(TvCommonConstants.EXTRA_SETUP_INTENT); setupIntent.putExtras(extras); try { startActivityForResult(setupIntent, REQUEST_START_SETUP_ACTIVITY); @@ -121,15 +107,14 @@ public class SetupPassthroughActivity extends Activity { sScanTimeoutMonitor = new ScanTimeoutMonitor(this); } sScanTimeoutMonitor.startMonitoring(); - TvSingletons.getSingletons(this).getEpgFetcher().onChannelScanStarted(); + EpgFetcher.getInstance(this).onChannelScanStarted(); } } } @Override public void onActivityResult(int requestCode, final int resultCode, final Intent data) { - if (DEBUG) - Log.d(TAG, "onActivityResult(" + requestCode + ", " + resultCode + ", " + data + ")"); + if (DEBUG) Log.d(TAG, "onActivityResult"); if (sScanTimeoutMonitor != null) { sScanTimeoutMonitor.stopMonitoring(); } @@ -137,17 +122,15 @@ public class SetupPassthroughActivity extends Activity { boolean setupComplete = requestCode == REQUEST_START_SETUP_ACTIVITY && resultCode == Activity.RESULT_OK; // Tells EpgFetcher that channel source setup is finished. - EpgFetcher epgFetcher = TvSingletons.getSingletons(this).getEpgFetcher(); if (mEpgFetcherDuringScan) { - epgFetcher.onChannelScanFinished(); + EpgFetcher.getInstance(this).onChannelScanFinished(); } if (!setupComplete) { setResult(resultCode, data); finish(); return; } - TvSingletons.getSingletons(this) - .getSetupUtils() + SetupUtils.getInstance(this) .onTvInputSetupFinished( mTvInputInfo.getId(), new Runnable() { @@ -209,7 +192,7 @@ public class SetupPassthroughActivity extends Activity { private ScanTimeoutMonitor(Context context) { mContext = context.getApplicationContext(); - mChannelDataManager = TvSingletons.getSingletons(context).getChannelDataManager(); + mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); } private void startMonitoring() { @@ -237,7 +220,7 @@ public class SetupPassthroughActivity extends Activity { private void onScanTimedOut() { stopMonitoring(); - TvSingletons.getSingletons(mContext).getEpgFetcher().onChannelScanFinished(); + EpgFetcher.getInstance(mContext).onChannelScanFinished(); } } } diff --git a/src/com/android/tv/Starter.java b/src/com/android/tv/Starter.java deleted file mode 100644 index 22fda0bd..00000000 --- a/src/com/android/tv/Starter.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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; - -import android.content.Context; -import android.util.Log; - -/** Initializes TvApplication. */ -public interface Starter { - - /** - * Initializes TvApplication. - * - * <p>Note: it should be called at the beginning of any Service.onCreate, Activity.onCreate, or - * BroadcastReceiver.onCreate. - */ - static void start(Context context) { - // TODO(b/63064354) TvApplication should not have to know if it is "the main process" - if (context.getApplicationContext() instanceof Starter) { - Starter starter = (Starter) context.getApplicationContext(); - starter.start(); - } else { - // Application context can be MockTvApplication. - Log.w("Start", "It is not a context of TvApplication"); - } - } - - void start(); -} diff --git a/src/com/android/tv/TimeShiftManager.java b/src/com/android/tv/TimeShiftManager.java index e37f190c..513fe3cd 100644 --- a/src/com/android/tv/TimeShiftManager.java +++ b/src/com/android/tv/TimeShiftManager.java @@ -146,8 +146,8 @@ public class TimeShiftManager { DISABLE_ACTION_THRESHOLD + 3 * REQUEST_CURRENT_POSITION_INTERVAL; /** * The current position sent from TIS can not be exactly the same as the current system time due - * to the elapsed time to pass the message from TIS to Live TV. So the boundary threshold - * is necessary. The same goes for the recording start time. It's the same {@link + * to the elapsed time to pass the message from TIS to Live TV. So the boundary threshold is + * necessary. The same goes for the recording start time. It's the same {@link * #REQUEST_CURRENT_POSITION_INTERVAL}. */ private static final long RECORDING_BOUNDARY_THRESHOLD = REQUEST_CURRENT_POSITION_INTERVAL; @@ -1095,9 +1095,10 @@ public class TimeShiftManager { SoftPreconditions.checkArgument( endTimeMs - startTimeMs <= TWO_WEEKS_MS, TAG, - "createDummyProgram: long duration of dummy programs are requested ( %s , %s)", - Utils.toTimeString(startTimeMs), - Utils.toTimeString(endTimeMs)); + "createDummyProgram: long duration of dummy programs are requested (" + + Utils.toTimeString(startTimeMs) + + ", " + + Utils.toTimeString(endTimeMs)); if (startTimeMs >= endTimeMs) { return Collections.emptyList(); } diff --git a/src/com/android/tv/TvApplication.java b/src/com/android/tv/TvApplication.java index 01fe5b07..549ab6d8 100644 --- a/src/com/android/tv/TvApplication.java +++ b/src/com/android/tv/TvApplication.java @@ -18,6 +18,7 @@ package com.android.tv; import android.annotation.TargetApi; import android.app.Activity; +import android.app.Application; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -28,24 +29,30 @@ import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; import android.media.tv.TvInputManager.TvInputCallback; +import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; +import android.os.StrictMode; import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; -import com.android.tv.common.BaseApplication; +import com.android.tv.analytics.Analytics; +import com.android.tv.analytics.StubAnalytics; +import com.android.tv.analytics.Tracker; +import com.android.tv.common.BuildConfig; +import com.android.tv.common.SharedPreferencesUtils; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonUtils; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.recording.RecordingStorageStatusManager; import com.android.tv.common.ui.setup.animation.SetupAnimationHelper; -import com.android.tv.common.util.Clock; -import com.android.tv.common.util.Debug; -import com.android.tv.common.util.SharedPreferencesUtils; +import com.android.tv.config.DefaultConfigManager; +import com.android.tv.config.RemoteConfig; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.PreviewDataManager; import com.android.tv.data.ProgramDataManager; import com.android.tv.data.epg.EpgFetcher; -import com.android.tv.data.epg.EpgFetcherImpl; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrDataManagerImpl; import com.android.tv.dvr.DvrManager; @@ -53,65 +60,80 @@ import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.DvrStorageStatusManager; import com.android.tv.dvr.DvrWatchedPositionManager; import com.android.tv.dvr.recorder.RecordingScheduler; +import com.android.tv.perf.EventNames; import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.StubPerformanceMonitor; +import com.android.tv.perf.TimerEvent; import com.android.tv.recommendation.ChannelPreviewUpdater; import com.android.tv.recommendation.RecordedProgramPreviewUpdater; import com.android.tv.tuner.TunerInputController; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.tvinput.TunerTvInputService; import com.android.tv.tuner.util.TunerInputInfoUtils; +import com.android.tv.util.AccountHelper; +import com.android.tv.util.Clock; +import com.android.tv.util.Debug; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.SetupUtils; +import com.android.tv.util.SystemProperties; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import java.util.List; -/** - * Live TV application. - * - * <p>This includes all the Google specific hooks. - */ -public abstract class TvApplication extends BaseApplication implements TvSingletons, Starter { +public class TvApplication extends Application implements ApplicationSingletons { private static final String TAG = "TvApplication"; private static final boolean DEBUG = false; + private static final TimerEvent sAppStartTimer = StubPerformanceMonitor.startBootstrapTimer(); - /** Namespace for LiveChannels configs. LiveChannels configs are kept in piper. */ - public static final String CONFIGNS_P4 = "configns:p4"; + /** + * An instance of {@link ApplicationSingletons}. Note that this can be set directly only for the + * test purpose. + */ + @VisibleForTesting public static ApplicationSingletons sAppSingletons; /** * Broadcast Action: The user has updated LC to a new version that supports tuner input. {@link - * com.android.tv.tuner.TunerInputController} will recevice this intent to check the - * existence of tuner input when the new version is first launched. + * com.android.tv.tuner.TunerInputController} will recevice this intent to check the existence + * of tuner input when the new version is first launched. */ public static final String ACTION_APPLICATION_FIRST_LAUNCHED = "com.android.tv.action.APPLICATION_FIRST_LAUNCHED"; private static final String PREFERENCE_IS_FIRST_LAUNCH = "is_first_launch"; + private RemoteConfig mRemoteConfig; private String mVersionName = ""; private final MainActivityWrapper mMainActivityWrapper = new MainActivityWrapper(); private SelectInputActivity mSelectInputActivity; + private Analytics mAnalytics; + private Tracker mTracker; + private TvInputManagerHelper mTvInputManagerHelper; private ChannelDataManager mChannelDataManager; private volatile ProgramDataManager mProgramDataManager; private PreviewDataManager mPreviewDataManager; private DvrManager mDvrManager; private DvrScheduleManager mDvrScheduleManager; private DvrDataManager mDvrDataManager; + private DvrStorageStatusManager mDvrStorageStatusManager; private DvrWatchedPositionManager mDvrWatchedPositionManager; private RecordingScheduler mRecordingScheduler; - private RecordingStorageStatusManager mDvrStorageStatusManager; @Nullable private InputSessionManager mInputSessionManager; - // STOP-SHIP: Remove this variable when Tuner Process is split to another application. + private AccountHelper mAccountHelper; // When this variable is null, we don't know in which process TvApplication runs. private Boolean mRunningInMainProcess; private PerformanceMonitor mPerformanceMonitor; - private TvInputManagerHelper mTvInputManagerHelper; - private boolean mStarted; - private EpgFetcher mEpgFetcher; - private TunerInputController mTunerInputController; @Override public void onCreate() { super.onCreate(); + if (!PermissionUtils.hasInternet(this)) { + // When an isolated process starts, just skip all the initialization. + return; + } + Debug.getTimer(Debug.TAG_START_UP_TIMER).start(); + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("Start TvApplication.onCreate"); SharedPreferencesUtils.initialize( this, new Runnable() { @@ -122,6 +144,9 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet } } }); + // TunerPreferences is used to enable/disable the tuner input even when TUNER feature is + // disabled. + TunerPreferences.initialize(this); try { PackageInfo pInfo = getPackageManager().getPackageInfo(getPackageName(), 0); mVersionName = pInfo.versionName; @@ -131,35 +156,66 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet } Log.i(TAG, "Starting Live TV " + getVersionName()); + // Only set StrictMode for ENG builds because the build server only produces userdebug + // builds. + if (BuildConfig.ENG && SystemProperties.ALLOW_STRICT_MODE.getValue()) { + StrictMode.ThreadPolicy.Builder threadPolicyBuilder = + new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog(); + StrictMode.VmPolicy.Builder vmPolicyBuilder = + new StrictMode.VmPolicy.Builder().detectAll().penaltyDeath(); + if (!TvCommonUtils.isRunningInTest()) { + threadPolicyBuilder.penaltyDialog(); + } + StrictMode.setThreadPolicy(threadPolicyBuilder.build()); + StrictMode.setVmPolicy(vmPolicyBuilder.build()); + } + if (BuildConfig.ENG && !SystemProperties.ALLOW_ANALYTICS_IN_ENG.getValue()) { + mAnalytics = StubAnalytics.getInstance(this); + } else { + mAnalytics = StubAnalytics.getInstance(this); + } + mTracker = mAnalytics.getDefaultTracker(); + getTvInputManagerHelper(); // In SetupFragment, transitions are set in the constructor. Because the fragment can be // created in Activity.onCreate() by the framework, SetupAnimationHelper should be // initialized here before Activity.onCreate() is called. - mEpgFetcher = EpgFetcherImpl.create(this); SetupAnimationHelper.initialize(this); - getTvInputManagerHelper(); Log.i(TAG, "Started Live TV " + mVersionName); Debug.getTimer(Debug.TAG_START_UP_TIMER).log("finish TvApplication.onCreate"); + getPerformanceMonitor().stopTimer(sAppStartTimer, EventNames.APPLICATION_ONCREATE); } - /** Initializes application. It is a noop if called twice. */ - @Override - public void start() { - if (mStarted) { + private void setCurrentRunningProcess(boolean isMainProcess) { + if (mRunningInMainProcess != null) { + SoftPreconditions.checkState(isMainProcess == mRunningInMainProcess); return; } - mStarted = true; - mRunningInMainProcess = true; - Debug.getTimer(Debug.TAG_START_UP_TIMER).log("start TvApplication.start"); + Debug.getTimer(Debug.TAG_START_UP_TIMER) + .log("start TvApplication.setCurrentRunningProcess"); + mRunningInMainProcess = isMainProcess; + if (CommonFeatures.DVR.isEnabled(this)) { + mDvrStorageStatusManager = new DvrStorageStatusManager(this, mRunningInMainProcess); + } + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + // Fetch remote config + getRemoteConfig().fetch(null); + return null; + } + }.execute(); if (mRunningInMainProcess) { getTvInputManagerHelper() .addCallback( new TvInputCallback() { @Override public void onInputAdded(String inputId) { - if (TvFeatures.TUNER.isEnabled(TvApplication.this) + if (Features.TUNER.isEnabled(TvApplication.this) && TextUtils.equals( - inputId, getEmbeddedTunerInputId())) { + inputId, + TunerTvInputService.getInputId( + TvApplication.this))) { TunerInputInfoUtils.updateTunerInputInfo( TvApplication.this); } @@ -171,7 +227,7 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet handleInputCountChanged(); } }); - if (TvFeatures.TUNER.isEnabled(this)) { + if (Features.TUNER.isEnabled(this)) { // If the tuner input service is added before the app is started, we need to // handle it here. TunerInputInfoUtils.updateTunerInputInfo(TvApplication.this); @@ -181,14 +237,15 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet mDvrManager = new DvrManager(this); mRecordingScheduler = RecordingScheduler.createScheduler(this); } - mEpgFetcher.startRoutineService(); + EpgFetcher.getInstance(this).startRoutineService(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ChannelPreviewUpdater.getInstance(this).startRoutineService(); RecordedProgramPreviewUpdater.getInstance(this) .updatePreviewDataForRecordedPrograms(); } } - Debug.getTimer(Debug.TAG_START_UP_TIMER).log("finish TvApplication.start"); + Debug.getTimer(Debug.TAG_START_UP_TIMER) + .log("finish TvApplication.setCurrentRunningProcess"); } private void checkTunerServiceOnFirstLaunch() { @@ -198,24 +255,13 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet boolean isFirstLaunch = sharedPreferences.getBoolean(PREFERENCE_IS_FIRST_LAUNCH, true); if (isFirstLaunch) { if (DEBUG) Log.d(TAG, "Congratulations, it's the first launch!"); - getTunerInputController() - .onCheckingUsbTunerStatus(this, ACTION_APPLICATION_FIRST_LAUNCHED); + TunerInputController.onCheckingUsbTunerStatus(this, ACTION_APPLICATION_FIRST_LAUNCHED); SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putBoolean(PREFERENCE_IS_FIRST_LAUNCH, false); editor.apply(); } } - @Override - public EpgFetcher getEpgFetcher() { - return mEpgFetcher; - } - - @Override - public synchronized SetupUtils getSetupUtils() { - return SetupUtils.createForTvSingletons(this); - } - /** Returns the {@link DvrManager}. */ @Override public DvrManager getDvrManager() { @@ -253,6 +299,18 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet return mInputSessionManager; } + /** Returns the {@link Analytics}. */ + @Override + public Analytics getAnalytics() { + return mAnalytics; + } + + /** Returns the default tracker. */ + @Override + public Tracker getTracker() { + return mTracker; + } + /** Returns {@link ChannelDataManager}. */ @Override public ChannelDataManager getChannelDataManager() { @@ -315,19 +373,11 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet return mDvrDataManager; } - @Override + /** Returns {@link DvrStorageStatusManager}. */ @TargetApi(Build.VERSION_CODES.N) - public RecordingStorageStatusManager getRecordingStorageStatusManager() { - if (mDvrStorageStatusManager == null) { - mDvrStorageStatusManager = new DvrStorageStatusManager(this); - } - return mDvrStorageStatusManager; - } - - /** Returns the main activity information. */ @Override - public MainActivityWrapper getMainActivityWrapper() { - return mMainActivityWrapper; + public DvrStorageStatusManager getDvrStorageStatusManager() { + return mDvrStorageStatusManager; } /** Returns {@link TvInputManagerHelper}. */ @@ -340,14 +390,28 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet return mTvInputManagerHelper; } + /** Returns the main activity information. */ + @Override + public MainActivityWrapper getMainActivityWrapper() { + return mMainActivityWrapper; + } + + /** Returns the {@link AccountHelper}. */ + @Override + public AccountHelper getAccountHelper() { + if (mAccountHelper == null) { + mAccountHelper = new AccountHelper(getApplicationContext()); + } + return mAccountHelper; + } + @Override - public synchronized TunerInputController getTunerInputController() { - if (mTunerInputController == null) { - mTunerInputController = - new TunerInputController( - ComponentName.unflattenFromString(getEmbeddedTunerInputId())); + public RemoteConfig getRemoteConfig() { + if (mRemoteConfig == null) { + // No need to synchronize this, it does not hurt to create two and throw one away. + mRemoteConfig = DefaultConfigManager.createInstance(this).getRemoteConfig(); } - return mTunerInputController; + return mRemoteConfig; } @Override @@ -355,6 +419,14 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet return mRunningInMainProcess != null && mRunningInMainProcess; } + @Override + public PerformanceMonitor getPerformanceMonitor() { + if (mPerformanceMonitor == null) { + mPerformanceMonitor = StubPerformanceMonitor.initialize(this); + } + return mPerformanceMonitor; + } + /** * SelectInputActivity is set in {@link SelectInputActivity#onCreate} and cleared in {@link * SelectInputActivity#onDestroy}. @@ -441,10 +513,9 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet } /** - * Checks the input counts and enable/disable TvActivity. Also upda162 the input list in {@link + * Checks the input counts and enable/disable TvActivity. Also updates the input list in {@link * SetupUtils}. */ - @Override public void handleInputCountChanged() { handleInputCountChanged(false, false, false); } @@ -453,8 +524,8 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet * Checks the input counts and enable/disable TvActivity. Also updates the input list in {@link * SetupUtils}. * - * @param calledByTunerServiceChanged true if it is called when BaseTunerTvInputService is - * enabled or disabled. + * @param calledByTunerServiceChanged true if it is called when TunerTvInputService is enabled + * or disabled. * @param tunerServiceEnabled it's available only when calledByTunerServiceChanged is true. * @param dontKillApp when TvActivity is enabled or disabled by this method, the app restarts by * default. But, if dontKillApp is true, the app won't restart. @@ -464,7 +535,7 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet TvInputManager inputManager = (TvInputManager) getSystemService(Context.TV_INPUT_SERVICE); boolean enable = (calledByTunerServiceChanged && tunerServiceEnabled) - || TvFeatures.UNHIDE.isEnabled(TvApplication.this); + || Features.UNHIDE.isEnabled(TvApplication.this); if (!enable) { List<TvInputInfo> inputs = inputManager.getTvInputList(); boolean skipTunerInputCheck = false; @@ -473,7 +544,7 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet for (TvInputInfo input : inputs) { if (calledByTunerServiceChanged && !tunerServiceEnabled - && getEmbeddedTunerInputId().equals(input.getId())) { + && TunerTvInputService.getInputId(this).equals(input.getId())) { continue; } if (input.getType() == TvInputInfo.TYPE_TUNER) { @@ -495,6 +566,34 @@ public abstract class TvApplication extends BaseApplication implements TvSinglet name, newState, dontKillApp ? PackageManager.DONT_KILL_APP : 0); Log.i(TAG, (enable ? "Un-hide" : "Hide") + " Live TV."); } - getSetupUtils().onInputListUpdated(inputManager); + SetupUtils.getInstance(TvApplication.this).onInputListUpdated(inputManager); + } + + /** Returns the @{@link ApplicationSingletons} using the application context. */ + public static ApplicationSingletons getSingletons(Context context) { + // No need to be "synchronized" because this doesn't create any instance. + if (sAppSingletons == null) { + sAppSingletons = (ApplicationSingletons) context.getApplicationContext(); + } + return sAppSingletons; + } + + /** + * Sets true, if TvApplication is running on the main process. If TvApplication runs on tuner + * process or other process, it sets false. + * + * <p>Note: it should be called at the beginning of Service.onCreate Activity.onCreate, or + * BroadcastReceiver.onCreate. When it is firstly called after launch, it runs process specific + * initializations. + */ + public static void setCurrentRunningProcess(Context context, boolean isMainProcess) { + // TODO(b/63064354) TvApplication should not have to know if it is "the main process" + if (context.getApplicationContext() instanceof TvApplication) { + TvApplication tvApplication = (TvApplication) context.getApplicationContext(); + tvApplication.setCurrentRunningProcess(isMainProcess); + } else { + // Application context can be MockTvApplication. + Log.w(TAG, "It is not a context of TvApplication"); + } } } diff --git a/src/com/android/tv/TvFeatures.java b/src/com/android/tv/TvFeatures.java deleted file mode 100644 index 64141e8c..00000000 --- a/src/com/android/tv/TvFeatures.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * 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; - -import static com.android.tv.common.feature.EngOnlyFeature.ENG_ONLY_FEATURE; -import static com.android.tv.common.feature.FeatureUtils.AND; -import static com.android.tv.common.feature.FeatureUtils.OFF; -import static com.android.tv.common.feature.FeatureUtils.ON; -import static com.android.tv.common.feature.FeatureUtils.OR; - -import android.content.Context; -import android.content.pm.PackageManager; -import android.os.Build; -import android.support.annotation.VisibleForTesting; -import com.android.tv.common.experiments.Experiments; -import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.feature.ExperimentFeature; -import com.android.tv.common.feature.Feature; -import com.android.tv.common.feature.FeatureUtils; -import com.android.tv.common.feature.GServiceFeature; -import com.android.tv.common.feature.PropertyFeature; -import com.android.tv.common.feature.Sdk; -import com.android.tv.common.feature.TestableFeature; -import com.android.tv.common.util.PermissionUtils; - - - -/** - * List of {@link Feature} for the Live TV App. - * - * <p>Remove the {@code Feature} once it is launched. - */ -public final class TvFeatures extends CommonFeatures { - - /** When enabled use system setting for turning on analytics. */ - public static final Feature ANALYTICS_OPT_IN = - ExperimentFeature.from(Experiments.ENABLE_ANALYTICS_VIA_CHECKBOX); - - /** - * Analytics that include sensitive information such as channel or program identifiers. - * - * <p>See <a href="http://b/22062676">b/22062676</a> - */ - public static final Feature ANALYTICS_V2 = AND(ON, ANALYTICS_OPT_IN); - - public static final Feature EPG_SEARCH = - PropertyFeature.create("feature_tv_use_epg_search", false); - - private static final String GSERVICE_KEY_UNHIDE = "live_channels_unhide"; - /** A flag which indicates that LC app is unhidden even when there is no input. */ - public static final Feature UNHIDE = - OR( - new GServiceFeature(GSERVICE_KEY_UNHIDE, false), - new Feature() { - @Override - public boolean isEnabled(Context context) { - // If LC app runs as non-system app, we unhide the app. - return !PermissionUtils.hasAccessAllEpg(context); - } - }); - - public static final Feature PICTURE_IN_PICTURE = - new Feature() { - private Boolean mEnabled; - - @Override - public boolean isEnabled(Context context) { - if (mEnabled == null) { - mEnabled = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N - && context.getPackageManager() - .hasSystemFeature( - PackageManager.FEATURE_PICTURE_IN_PICTURE); - } - return mEnabled; - } - }; - - /** Enable a conflict dialog between currently watched channel and upcoming recording. */ - public static final Feature SHOW_UPCOMING_CONFLICT_DIALOG = OFF; - - /** Use input blacklist to disable partner's tuner input. */ - public static final Feature USE_PARTNER_INPUT_BLACKLIST = ON; - - @VisibleForTesting - public static final Feature TEST_FEATURE = PropertyFeature.create("test_feature", false); - - private TvFeatures() {} -} diff --git a/src/com/android/tv/analytics/SendChannelStatusRunnable.java b/src/com/android/tv/analytics/SendChannelStatusRunnable.java index 2f208828..601e82f7 100644 --- a/src/com/android/tv/analytics/SendChannelStatusRunnable.java +++ b/src/com/android/tv/analytics/SendChannelStatusRunnable.java @@ -31,8 +31,7 @@ import java.util.concurrent.TimeUnit; * * <p> * - * <p>This should only be started from a user activity like {@link - * com.android.tv.MainActivity}. + * <p>This should only be started from a user activity like {@link com.android.tv.MainActivity}. */ @MainThread public class SendChannelStatusRunnable implements Runnable { diff --git a/src/com/android/tv/app/LiveTvApplication.java b/src/com/android/tv/app/LiveTvApplication.java deleted file mode 100644 index 1c4f1522..00000000 --- a/src/com/android/tv/app/LiveTvApplication.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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.app; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.media.tv.TvContract; -import com.android.tv.tuner.tvinput.LiveTvTunerTvInputService; -import com.android.tv.TvApplication; -import com.android.tv.analytics.Analytics; -import com.android.tv.analytics.StubAnalytics; -import com.android.tv.analytics.Tracker; -import com.android.tv.common.CommonConstants; -import com.android.tv.common.config.DefaultConfigManager; -import com.android.tv.common.config.api.RemoteConfig; -import com.android.tv.common.experiments.ExperimentLoader; -import com.android.tv.common.util.CommonUtils; -import com.android.tv.data.epg.EpgReader; -import com.android.tv.data.epg.StubEpgReader; -import com.android.tv.perf.PerformanceMonitor; -import com.android.tv.perf.StubPerformanceMonitor; -import com.android.tv.tuner.setup.LiveTvTunerSetupActivity; -import com.android.tv.util.account.AccountHelper; -import com.android.tv.util.account.AccountHelperImpl; -import javax.inject.Provider; - -/** The top level application for Live TV. */ -public class LiveTvApplication extends TvApplication { - protected static final String TV_ACTIVITY_CLASS_NAME = "com.android.tv.TvActivity"; - - private final StubPerformanceMonitor performanceMonitor = new StubPerformanceMonitor(); - private final Provider<EpgReader> mEpgReaderProvider = - new Provider<EpgReader>() { - - @Override - public EpgReader get() { - return new StubEpgReader(LiveTvApplication.this); - } - }; - - private AccountHelper mAccountHelper; - private Analytics mAnalytics; - private Tracker mTracker; - private String mEmbeddedInputId; - private RemoteConfig mRemoteConfig; - private ExperimentLoader mExperimentLoader; - - /** Returns the {@link AccountHelperImpl}. */ - @Override - public AccountHelper getAccountHelper() { - if (mAccountHelper == null) { - mAccountHelper = new AccountHelperImpl(getApplicationContext()); - } - return mAccountHelper; - } - - @Override - public synchronized PerformanceMonitor getPerformanceMonitor() { - return performanceMonitor; - } - - @Override - public Provider<EpgReader> providesEpgReader() { - return mEpgReaderProvider; - } - - @Override - public ExperimentLoader getExperimentLoader() { - mExperimentLoader = new ExperimentLoader(); - return mExperimentLoader; - } - - /** Returns the {@link Analytics}. */ - @Override - public synchronized Analytics getAnalytics() { - if (mAnalytics == null) { - mAnalytics = StubAnalytics.getInstance(this); - } - return mAnalytics; - } - - /** Returns the default tracker. */ - @Override - public synchronized Tracker getTracker() { - if (mTracker == null) { - mTracker = getAnalytics().getDefaultTracker(); - } - return mTracker; - } - - @Override - public Intent getTunerSetupIntent(Context context) { - // Make an intent to launch the setup activity of TV tuner input. - Intent intent = - CommonUtils.createSetupIntent( - new Intent(context, LiveTvTunerSetupActivity.class), mEmbeddedInputId); - intent.putExtra(CommonConstants.EXTRA_INPUT_ID, mEmbeddedInputId); - Intent tvActivityIntent = new Intent(); - tvActivityIntent.setComponent(new ComponentName(context, TV_ACTIVITY_CLASS_NAME)); - intent.putExtra(CommonConstants.EXTRA_ACTIVITY_AFTER_COMPLETION, tvActivityIntent); - return intent; - } - - @Override - public synchronized String getEmbeddedTunerInputId() { - if (mEmbeddedInputId == null) { - mEmbeddedInputId = - TvContract.buildInputId( - new ComponentName(this, LiveTvTunerTvInputService.class)); - } - return mEmbeddedInputId; - } - - @Override - public RemoteConfig getRemoteConfig() { - if (mRemoteConfig == null) { - // No need to synchronize this, it does not hurt to create two and throw one away. - mRemoteConfig = DefaultConfigManager.createInstance(this).getRemoteConfig(); - } - return mRemoteConfig; - } -} diff --git a/src/com/android/tv/config/ConfigKeys.java b/src/com/android/tv/config/ConfigKeys.java new file mode 100644 index 00000000..135017ae --- /dev/null +++ b/src/com/android/tv/config/ConfigKeys.java @@ -0,0 +1,23 @@ +/* + * 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.config; + +/** Static list of config keys. */ +public final class ConfigKeys { + + private ConfigKeys() {} +} diff --git a/src/com/android/tv/config/DefaultConfigManager.java b/src/com/android/tv/config/DefaultConfigManager.java new file mode 100644 index 00000000..4d754d1f --- /dev/null +++ b/src/com/android/tv/config/DefaultConfigManager.java @@ -0,0 +1,54 @@ +/* + * 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.config; + +import android.content.Context; + +/** Stub Remote Config. */ +public class DefaultConfigManager { + public static final long DEFAULT_LONG_VALUE = 0; + + public static DefaultConfigManager createInstance(Context context) { + return new DefaultConfigManager(); + } + + private StubRemoteConfig mRemoteConfig = new StubRemoteConfig(); + + public RemoteConfig getRemoteConfig() { + return mRemoteConfig; + } + + private static class StubRemoteConfig implements RemoteConfig { + @Override + public void fetch(OnRemoteConfigUpdatedListener listener) {} + + @Override + public String getString(String key) { + return null; + } + + @Override + public boolean getBoolean(String key) { + return false; + } + + @Override + public long getLong(String key) { + return DEFAULT_LONG_VALUE; + } + } +} diff --git a/src/com/android/tv/config/RemoteConfig.java b/src/com/android/tv/config/RemoteConfig.java new file mode 100644 index 00000000..d72a1f3f --- /dev/null +++ b/src/com/android/tv/config/RemoteConfig.java @@ -0,0 +1,43 @@ +/* + * 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.config; + +/** + * Manages Live TV Configuration, allowing remote updates. + * + * <p>This is a thin wrapper around <a + * href="https://firebase.google.com/docs/remote-config/"></a>Firebase Remote Config</a> + */ +public interface RemoteConfig { + + /** Notified on successful completion of a {@link #fetch)} */ + interface OnRemoteConfigUpdatedListener { + void onRemoteConfigUpdated(); + } + + /** Starts a fetch and notifies {@code listener} on successful completion. */ + void fetch(OnRemoteConfigUpdatedListener listener); + + /** Gets value as a string corresponding to the specified key. */ + String getString(String key); + + /** Gets value as a boolean corresponding to the specified key. */ + boolean getBoolean(String key); + + /** Gets value as a long corresponding to the specified key. */ + long getLong(String key); +} diff --git a/src/com/android/tv/config/RemoteConfigFeature.java b/src/com/android/tv/config/RemoteConfigFeature.java new file mode 100644 index 00000000..c22446be --- /dev/null +++ b/src/com/android/tv/config/RemoteConfigFeature.java @@ -0,0 +1,40 @@ +/* + * 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.config; + +import android.content.Context; +import com.android.tv.TvApplication; +import com.android.tv.common.feature.Feature; + +/** A {@link Feature} controlled by a {@link RemoteConfig} boolean. */ +public class RemoteConfigFeature implements Feature { + private final String mKey; + + /** Creates a {@link RemoteConfigFeature for the {@code key}. */ + public static RemoteConfigFeature fromKey(String key) { + return new RemoteConfigFeature(key); + } + + private RemoteConfigFeature(String key) { + mKey = key; + } + + @Override + public boolean isEnabled(Context context) { + return TvApplication.getSingletons(context).getRemoteConfig().getBoolean(mKey); + } +} diff --git a/src/com/android/tv/config/RemoteConfigUtils.java b/src/com/android/tv/config/RemoteConfigUtils.java new file mode 100644 index 00000000..09d85239 --- /dev/null +++ b/src/com/android/tv/config/RemoteConfigUtils.java @@ -0,0 +1,42 @@ +/* + * 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.config; + +import android.content.Context; +import android.util.Log; +import com.android.tv.TvApplication; + +/** A utility class to get the remote config. */ +public class RemoteConfigUtils { + private static final String TAG = "RemoteConfigUtils"; + private static final boolean DEBUG = false; + + private RemoteConfigUtils() {} + + public static long getRemoteConfig(Context context, String key, long defaultValue) { + RemoteConfig remoteConfig = TvApplication.getSingletons(context).getRemoteConfig(); + try { + long remoteValue = remoteConfig.getLong(key); + if (DEBUG) Log.d(TAG, "Got " + key + " from remote: " + remoteValue); + return remoteValue; + } catch (Exception e) { + Log.w(TAG, "Cannot get " + key + " from RemoteConfig", e); + } + if (DEBUG) Log.d(TAG, "Use default value " + defaultValue); + return defaultValue; + } +} diff --git a/src/com/android/tv/customization/CustomAction.java b/src/com/android/tv/customization/CustomAction.java new file mode 100644 index 00000000..77a5ae5e --- /dev/null +++ b/src/com/android/tv/customization/CustomAction.java @@ -0,0 +1,68 @@ +/* + * 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.customization; + +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; + +/** Describes a custom option defined in customization package. This will be added to main menu. */ +public class CustomAction implements Comparable<CustomAction> { + private static final int POSITION_THRESHOLD = 100; + + private final int mPositionPriority; + private final String mTitle; + private final Drawable mIconDrawable; + private final Intent mIntent; + + public CustomAction(int positionPriority, String title, Drawable iconDrawable, Intent intent) { + mPositionPriority = positionPriority; + mTitle = title; + mIconDrawable = iconDrawable; + mIntent = intent; + } + + /** + * Returns if this option comes before the existing items. Note that custom options can only be + * placed at the front or back. (i.e. cannot be added in the middle of existing options.) + * + * @return {@code true} if it goes to the beginning. {@code false} if it goes to the end. + */ + public boolean isFront() { + return mPositionPriority < POSITION_THRESHOLD; + } + + @Override + public int compareTo(@NonNull CustomAction another) { + return mPositionPriority - another.mPositionPriority; + } + + /** Returns title. */ + public String getTitle() { + return mTitle; + } + + /** Returns icon drawable. */ + public Drawable getIconDrawable() { + return mIconDrawable; + } + + /** Returns intent to launch when this option is clicked. */ + public Intent getIntent() { + return mIntent; + } +} diff --git a/src/com/android/tv/customization/TvCustomizationManager.java b/src/com/android/tv/customization/TvCustomizationManager.java new file mode 100644 index 00000000..7d21c6d2 --- /dev/null +++ b/src/com/android/tv/customization/TvCustomizationManager.java @@ -0,0 +1,271 @@ +/* + * 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.customization; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.support.annotation.IntDef; +import android.text.TextUtils; +import android.util.Log; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class TvCustomizationManager { + private static final String TAG = "TvCustomizationManager"; + private static final boolean DEBUG = false; + + private static final String[] CUSTOMIZE_PERMISSIONS = { + "com.android.tv.permission.CUSTOMIZE_TV_APP" + }; + + private static final String CATEGORY_TV_CUSTOMIZATION = "com.android.tv.category"; + + /** Row IDs to share customized actions. Only rows listed below can have customized action. */ + public static final String ID_OPTIONS_ROW = "options_row"; + + public static final String ID_PARTNER_ROW = "partner_row"; + + @IntDef({TRICKPLAY_MODE_ENABLED, TRICKPLAY_MODE_DISABLED, TRICKPLAY_MODE_USE_EXTERNAL_STORAGE}) + @Retention(RetentionPolicy.SOURCE) + public @interface TRICKPLAY_MODE {} + + public static final int TRICKPLAY_MODE_ENABLED = 0; + public static final int TRICKPLAY_MODE_DISABLED = 1; + public static final int TRICKPLAY_MODE_USE_EXTERNAL_STORAGE = 2; + + private static final String[] TRICKPLAY_MODE_STRINGS = { + "enabled", "disabled", "use_external_storage_only" + }; + + private static final HashMap<String, String> INTENT_CATEGORY_TO_ROW_ID; + + static { + INTENT_CATEGORY_TO_ROW_ID = new HashMap<>(); + INTENT_CATEGORY_TO_ROW_ID.put(CATEGORY_TV_CUSTOMIZATION + ".OPTIONS_ROW", ID_OPTIONS_ROW); + INTENT_CATEGORY_TO_ROW_ID.put(CATEGORY_TV_CUSTOMIZATION + ".PARTNER_ROW", ID_PARTNER_ROW); + } + + private static final String RES_ID_PARTNER_ROW_TITLE = "partner_row_title"; + private static final String RES_ID_HAS_LINUX_DVB_BUILT_IN_TUNER = + "has_linux_dvb_built_in_tuner"; + private static final String RES_ID_TRICKPLAY_MODE = "trickplay_mode"; + + private static final String RES_TYPE_STRING = "string"; + private static final String RES_TYPE_BOOLEAN = "bool"; + + private static String sCustomizationPackage; + private static Boolean sHasLinuxDvbBuiltInTuner; + private static @TRICKPLAY_MODE Integer sTrickplayMode; + + private final Context mContext; + private boolean mInitialized; + + private String mPartnerRowTitle; + private final Map<String, List<CustomAction>> mRowIdToCustomActionsMap = new HashMap<>(); + + public TvCustomizationManager(Context context) { + mContext = context; + mInitialized = false; + } + + /** + * Returns {@code true} if there's a customization package installed and it specifies built-in + * tuner devices are available. The built-in tuner should support DVB API to be recognized by + * Live TV. + */ + public static boolean hasLinuxDvbBuiltInTuner(Context context) { + if (sHasLinuxDvbBuiltInTuner == null) { + if (TextUtils.isEmpty(getCustomizationPackageName(context))) { + sHasLinuxDvbBuiltInTuner = false; + } else { + try { + Resources res = + context.getPackageManager() + .getResourcesForApplication(sCustomizationPackage); + int resId = + res.getIdentifier( + RES_ID_HAS_LINUX_DVB_BUILT_IN_TUNER, + RES_TYPE_BOOLEAN, + sCustomizationPackage); + sHasLinuxDvbBuiltInTuner = resId != 0 && res.getBoolean(resId); + } catch (NameNotFoundException e) { + sHasLinuxDvbBuiltInTuner = false; + } + } + } + return sHasLinuxDvbBuiltInTuner; + } + + public static @TRICKPLAY_MODE int getTrickplayMode(Context context) { + if (sTrickplayMode == null) { + if (TextUtils.isEmpty(getCustomizationPackageName(context))) { + sTrickplayMode = TRICKPLAY_MODE_ENABLED; + } else { + try { + String customization = null; + Resources res = + context.getPackageManager() + .getResourcesForApplication(sCustomizationPackage); + int resId = + res.getIdentifier( + RES_ID_TRICKPLAY_MODE, RES_TYPE_STRING, sCustomizationPackage); + customization = resId == 0 ? null : res.getString(resId); + sTrickplayMode = TRICKPLAY_MODE_ENABLED; + if (customization != null) { + for (int i = 0; i < TRICKPLAY_MODE_STRINGS.length; ++i) { + if (TRICKPLAY_MODE_STRINGS[i].equalsIgnoreCase(customization)) { + sTrickplayMode = i; + break; + } + } + } + } catch (NameNotFoundException e) { + sTrickplayMode = TRICKPLAY_MODE_ENABLED; + } + } + } + return sTrickplayMode; + } + + private static String getCustomizationPackageName(Context context) { + if (sCustomizationPackage == null) { + List<PackageInfo> packageInfos = + context.getPackageManager() + .getPackagesHoldingPermissions(CUSTOMIZE_PERMISSIONS, 0); + sCustomizationPackage = packageInfos.size() == 0 ? "" : packageInfos.get(0).packageName; + } + return sCustomizationPackage; + } + + /** Initialize TV customization options. Run this API only on the main thread. */ + public void initialize() { + if (mInitialized) { + return; + } + mInitialized = true; + if (!TextUtils.isEmpty(getCustomizationPackageName(mContext))) { + buildCustomActions(); + buildPartnerRow(); + } + } + + private void buildCustomActions() { + mRowIdToCustomActionsMap.clear(); + PackageManager pm = mContext.getPackageManager(); + for (String intentCategory : INTENT_CATEGORY_TO_ROW_ID.keySet()) { + Intent customOptionIntent = new Intent(Intent.ACTION_MAIN); + customOptionIntent.addCategory(intentCategory); + + List<ResolveInfo> activities = + pm.queryIntentActivities( + customOptionIntent, + PackageManager.GET_RECEIVERS + | PackageManager.GET_RESOLVED_FILTER + | PackageManager.GET_META_DATA); + for (ResolveInfo info : activities) { + String packageName = info.activityInfo.packageName; + if (!TextUtils.equals(packageName, sCustomizationPackage)) { + Log.w( + TAG, + "A customization package " + + sCustomizationPackage + + " already exist. Ignoring " + + packageName); + continue; + } + + int position = info.filter.getPriority(); + String title = info.loadLabel(pm).toString(); + Drawable drawable = info.loadIcon(pm); + Intent intent = new Intent(Intent.ACTION_MAIN); + intent.addCategory(intentCategory); + intent.setClassName(sCustomizationPackage, info.activityInfo.name); + + String rowId = INTENT_CATEGORY_TO_ROW_ID.get(intentCategory); + List<CustomAction> actions = mRowIdToCustomActionsMap.get(rowId); + if (actions == null) { + actions = new ArrayList<>(); + mRowIdToCustomActionsMap.put(rowId, actions); + } + actions.add(new CustomAction(position, title, drawable, intent)); + } + } + // Sort items by position + for (List<CustomAction> actions : mRowIdToCustomActionsMap.values()) { + Collections.sort(actions); + } + + if (DEBUG) { + Log.d(TAG, "Dumping custom actions"); + for (String id : mRowIdToCustomActionsMap.keySet()) { + for (CustomAction action : mRowIdToCustomActionsMap.get(id)) { + Log.d( + TAG, + "Custom row rowId=" + + id + + " title=" + + action.getTitle() + + " class=" + + action.getIntent()); + } + } + Log.d(TAG, "Dumping custom actions - end of dump"); + } + } + + /** + * Returns custom actions for given row id. + * + * <p>Row ID is one of ID_OPTIONS_ROW or ID_PARTNER_ROW. + */ + public List<CustomAction> getCustomActions(String rowId) { + return mRowIdToCustomActionsMap.get(rowId); + } + + private void buildPartnerRow() { + mPartnerRowTitle = null; + Resources res; + try { + res = mContext.getPackageManager().getResourcesForApplication(sCustomizationPackage); + } catch (NameNotFoundException e) { + Log.w(TAG, "Could not get resources for package " + sCustomizationPackage); + return; + } + int resId = + res.getIdentifier(RES_ID_PARTNER_ROW_TITLE, RES_TYPE_STRING, sCustomizationPackage); + if (resId != 0) { + mPartnerRowTitle = res.getString(resId); + } + if (DEBUG) Log.d(TAG, "Partner row title [" + mPartnerRowTitle + "]"); + } + + /** Returns partner row title. */ + public String getPartnerRowTitle() { + return mPartnerRowTitle; + } +} diff --git a/src/com/android/tv/data/Channel.java b/src/com/android/tv/data/Channel.java index 1204a49f..eda188e4 100644 --- a/src/com/android/tv/data/Channel.java +++ b/src/com/android/tv/data/Channel.java @@ -28,8 +28,7 @@ import android.support.annotation.UiThread; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; -import com.android.tv.common.CommonConstants; -import com.android.tv.common.util.CommonUtils; +import com.android.tv.common.TvCommonConstants; import com.android.tv.util.ImageLoader; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; @@ -125,7 +124,7 @@ public final class Channel { channel.mAppLinkIconUri = cursor.getString(index++); channel.mAppLinkPosterArtUri = cursor.getString(index++); channel.mAppLinkIntentUri = cursor.getString(index++); - if (CommonUtils.isBundledInput(channel.mInputId)) { + if (Utils.isBundledInput(channel.mInputId)) { channel.mRecordingProhibited = cursor.getInt(index++) != 0; } return channel; @@ -626,7 +625,7 @@ public final class Channel { if (intent.resolveActivityInfo(pm, 0) != null) { mAppLinkIntent = intent; mAppLinkIntent.putExtra( - CommonConstants.EXTRA_APP_LINK_CHANNEL_URI, getUri().toString()); + TvCommonConstants.EXTRA_APP_LINK_CHANNEL_URI, getUri().toString()); mAppLinkType = APP_LINK_TYPE_CHANNEL; return; } else { @@ -643,7 +642,7 @@ public final class Channel { mAppLinkIntent = pm.getLeanbackLaunchIntentForPackage(mPackageName); if (mAppLinkIntent != null) { mAppLinkIntent.putExtra( - CommonConstants.EXTRA_APP_LINK_CHANNEL_URI, getUri().toString()); + TvCommonConstants.EXTRA_APP_LINK_CHANNEL_URI, getUri().toString()); mAppLinkType = APP_LINK_TYPE_APP; } } diff --git a/src/com/android/tv/data/ChannelDataManager.java b/src/com/android/tv/data/ChannelDataManager.java index 68fbdb6a..e4d1cd85 100644 --- a/src/com/android/tv/data/ChannelDataManager.java +++ b/src/com/android/tv/data/ChannelDataManager.java @@ -38,11 +38,11 @@ import android.support.annotation.VisibleForTesting; import android.util.ArraySet; import android.util.Log; import android.util.MutableInt; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.WeakHandler; -import com.android.tv.common.util.PermissionUtils; -import com.android.tv.common.util.SharedPreferencesUtils; import com.android.tv.util.AsyncDbTask; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import java.io.IOException; @@ -62,7 +62,6 @@ import java.util.concurrent.CopyOnWriteArraySet; * methods are called in only the main thread. */ @AnyThread -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class ChannelDataManager { private static final String TAG = "ChannelDataManager"; private static final boolean DEBUG = false; diff --git a/src/com/android/tv/data/ChannelLogoFetcher.java b/src/com/android/tv/data/ChannelLogoFetcher.java index 8aaaf73a..2dc43102 100644 --- a/src/com/android/tv/data/ChannelLogoFetcher.java +++ b/src/com/android/tv/data/ChannelLogoFetcher.java @@ -28,10 +28,10 @@ import android.os.RemoteException; import android.support.annotation.MainThread; import android.text.TextUtils; import android.util.Log; -import com.android.tv.common.util.PermissionUtils; -import com.android.tv.common.util.SharedPreferencesUtils; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.util.BitmapUtils; import com.android.tv.util.BitmapUtils.ScaledBitmapInfo; +import com.android.tv.util.PermissionUtils; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; @@ -42,7 +42,6 @@ import java.util.Map; * Fetches channel logos from the cloud into the database. It's for the channels which have no logos * or need update logos. This class is thread safe. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class ChannelLogoFetcher { private static final String TAG = "ChannelLogoFetcher"; private static final boolean DEBUG = false; diff --git a/src/com/android/tv/data/ChannelNumber.java b/src/com/android/tv/data/ChannelNumber.java index 1623b33d..63f8a972 100644 --- a/src/com/android/tv/data/ChannelNumber.java +++ b/src/com/android/tv/data/ChannelNumber.java @@ -19,7 +19,7 @@ package com.android.tv.data; import android.support.annotation.NonNull; import android.text.TextUtils; import android.view.KeyEvent; -import com.android.tv.common.util.StringUtils; +import com.android.tv.util.StringUtils; import java.util.Objects; /** A convenience class to handle channel number. */ @@ -43,23 +43,6 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { reset(); } - /** - * {@code lhs} and {@code rhs} are equivalent if {@link ChannelNumber#compare(String, String)} - * is 0 or if only one has a delimiter and both {@link ChannelNumber#majorNumber} equals. - */ - public static boolean equivalent(String lhs, String rhs) { - if (compare(lhs, rhs) == 0) { - return true; - } - // Match if only one has delimiter - ChannelNumber lhsNumber = parseChannelNumber(lhs); - ChannelNumber rhsNumber = parseChannelNumber(rhs); - return lhsNumber != null - && rhsNumber != null - && lhsNumber.hasDelimiter != rhsNumber.hasDelimiter - && lhsNumber.majorNumber.equals(rhsNumber.majorNumber); - } - public void reset() { setChannelNumber("", false, ""); } diff --git a/src/com/android/tv/data/InternalDataUtils.java b/src/com/android/tv/data/InternalDataUtils.java index 99a3d4e8..4c30d395 100644 --- a/src/com/android/tv/data/InternalDataUtils.java +++ b/src/com/android/tv/data/InternalDataUtils.java @@ -33,7 +33,6 @@ import java.util.List; * android.media.tv.TvContract.Programs#COLUMN_INTERNAL_PROVIDER_DATA} field in the {@link * android.media.tv.TvContract.Programs}. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public final class InternalDataUtils { private static final boolean DEBUG = false; private static final String TAG = "InternalDataUtils"; diff --git a/src/com/android/tv/data/Lineup.java b/src/com/android/tv/data/Lineup.java index 4393cd3d..0f11c1cc 100644 --- a/src/com/android/tv/data/Lineup.java +++ b/src/com/android/tv/data/Lineup.java @@ -19,45 +19,23 @@ package com.android.tv.data; import android.support.annotation.IntDef; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.util.Collections; -import java.util.List; /** A class that represents a lineup. */ public class Lineup { /** The ID of this lineup. */ - public String getId() { - return id; - } + public final String id; /** The type associated with this lineup. */ - public int getType() { - return type; - } - - /** The human readable name associated with this lineup. */ - public String getName() { - return name; - } + public final int type; /** The human readable name associated with this lineup. */ - public String getLocation() { - return location; - } - - /** An unmodifiable list of channel numbers that this lineup has. */ - public List<String> getChannels() { - return channels; - } + public final String name; - private final String id; - - private final int type; - - private final String name; - - private final String location; - - private final List<String> channels; + /** + * Location this lineup can be found. This is a human readable description of a geographic + * location. + */ + public final String location; @Retention(RetentionPolicy.SOURCE) @IntDef({ @@ -66,9 +44,7 @@ public class Lineup { LINEUP_BROADCAST_DIGITAL, LINEUP_BROADCAST_ANALOG, LINEUP_IPTV, - LINEUP_MVPD, - LINEUP_INTERNET, - LINEUP_OTHER + LINEUP_MVPD }) public @interface LineupType {} @@ -88,23 +64,16 @@ public class Lineup { public static final int LINEUP_IPTV = 4; /** - * Indicates the lineup is either satellite, cable or IPTV but we are not sure which specific + * Indicates the lineup is either satelite, cable or IPTV but we are not sure which specific * type. */ public static final int LINEUP_MVPD = 5; - /** Lineup type for Internet. */ - public static final int LINEUP_INTERNET = 6; - - /** Lineup type for other. */ - public static final int LINEUP_OTHER = 7; - /** Creates a lineup. */ - public Lineup(String id, int type, String name, String location, List<String> channels) { + public Lineup(String id, int type, String name, String location) { this.id = id; this.type = type; this.name = name; this.location = location; - this.channels = Collections.unmodifiableList(channels); } } diff --git a/src/com/android/tv/data/PreviewDataManager.java b/src/com/android/tv/data/PreviewDataManager.java index ac78147b..b103a5d7 100644 --- a/src/com/android/tv/data/PreviewDataManager.java +++ b/src/com/android/tv/data/PreviewDataManager.java @@ -36,7 +36,7 @@ import android.support.media.tv.PreviewProgram; import android.util.Log; import android.util.Pair; import com.android.tv.R; -import com.android.tv.common.util.PermissionUtils; +import com.android.tv.util.PermissionUtils; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.HashMap; @@ -47,22 +47,22 @@ import java.util.concurrent.CopyOnWriteArraySet; /** Class to manage the preview data. */ @TargetApi(Build.VERSION_CODES.O) @MainThread -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class PreviewDataManager { private static final String TAG = "PreviewDataManager"; - private static final boolean DEBUG = false; + // STOPSHIP: set it to false. + private static final boolean DEBUG = true; /** Invalid preview channel ID. */ public static final long INVALID_PREVIEW_CHANNEL_ID = -1; - @IntDef({TYPE_DEFAULT_PREVIEW_CHANNEL, TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL}) + @IntDef({(int) TYPE_DEFAULT_PREVIEW_CHANNEL, (int) TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL}) @Retention(RetentionPolicy.SOURCE) public @interface PreviewChannelType {} /** Type of default preview channel */ - public static final int TYPE_DEFAULT_PREVIEW_CHANNEL = 1; + public static final long TYPE_DEFAULT_PREVIEW_CHANNEL = 1; /** Type of recorded program channel */ - public static final int TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL = 2; + public static final long TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL = 2; private final Context mContext; private final ContentResolver mContentResolver; @@ -604,8 +604,7 @@ public class PreviewDataManager { .setPosterArtUri(program.getPosterArtUri()) .setIntentUri(program.getIntentUri()) .setPreviewVideoUri(program.getPreviewVideoUri()) - .setInternalProviderId(Long.toString(program.getId())) - .setContentId(program.getIntentUri().toString()); + .setInternalProviderId(Long.toString(program.getId())); return builder.build(); } diff --git a/src/com/android/tv/data/PreviewProgramContent.java b/src/com/android/tv/data/PreviewProgramContent.java index 252092b0..845ca9d4 100644 --- a/src/com/android/tv/data/PreviewProgramContent.java +++ b/src/com/android/tv/data/PreviewProgramContent.java @@ -17,18 +17,17 @@ package com.android.tv.data; import android.content.Context; +import android.media.tv.TvContract; import android.net.Uri; -import android.support.annotation.VisibleForTesting; -import android.support.media.tv.TvContractCompat; import android.text.TextUtils; import android.util.Pair; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.data.RecordedProgram; import java.util.Objects; /** A class to store the content of preview programs. */ public class PreviewProgramContent { - @VisibleForTesting static final String PARAM_INPUT = "input"; + private static final String PARAM_INPUT = "input"; private long mId; private long mPreviewChannelId; @@ -44,30 +43,17 @@ public class PreviewProgramContent { public static PreviewProgramContent createFromProgram( Context context, long previewChannelId, Program program) { Channel channel = - TvSingletons.getSingletons(context) + TvApplication.getSingletons(context) .getChannelDataManager() .getChannel(program.getChannelId()); - return channel == null ? null : createFromProgram(previewChannelId, program, channel); - } - - /** Create preview program content from {@link RecordedProgram} */ - public static PreviewProgramContent createFromRecordedProgram( - Context context, long previewChannelId, RecordedProgram recordedProgram) { - Channel channel = - TvSingletons.getSingletons(context) - .getChannelDataManager() - .getChannel(recordedProgram.getChannelId()); - return createFromRecordedProgram(previewChannelId, recordedProgram, channel); - } - - @VisibleForTesting - static PreviewProgramContent createFromProgram( - long previewChannelId, Program program, Channel channel) { + if (channel == null) { + return null; + } String channelDisplayName = channel.getDisplayName(); return new PreviewProgramContent.Builder() .setId(program.getId()) .setPreviewChannelId(previewChannelId) - .setType(TvContractCompat.PreviewPrograms.TYPE_CHANNEL) + .setType(TvContract.PreviewPrograms.TYPE_CHANNEL) .setLive(true) .setTitle(program.getTitle()) .setDescription( @@ -82,15 +68,22 @@ public class PreviewProgramContent { .build(); } - @VisibleForTesting - static PreviewProgramContent createFromRecordedProgram( - long previewChannelId, RecordedProgram recordedProgram, Channel channel) { - String channelDisplayName = channel == null ? null : channel.getDisplayName(); - Uri recordedProgramUri = TvContractCompat.buildRecordedProgramUri(recordedProgram.getId()); + /** Create preview program content from {@link RecordedProgram} */ + public static PreviewProgramContent createFromRecordedProgram( + Context context, long previewChannelId, RecordedProgram recordedProgram) { + Channel channel = + TvApplication.getSingletons(context) + .getChannelDataManager() + .getChannel(recordedProgram.getChannelId()); + String channelDisplayName = null; + if (channel != null) { + channelDisplayName = channel.getDisplayName(); + } + Uri recordedProgramUri = TvContract.buildRecordedProgramUri(recordedProgram.getId()); return new PreviewProgramContent.Builder() .setId(recordedProgram.getId()) .setPreviewChannelId(previewChannelId) - .setType(TvContractCompat.PreviewPrograms.TYPE_CLIP) + .setType(TvContract.PreviewPrograms.TYPE_CLIP) .setTitle(recordedProgram.getTitle()) .setDescription(channelDisplayName != null ? channelDisplayName : "") .setPosterArtUri(Uri.parse(recordedProgram.getPosterArtUri())) diff --git a/src/com/android/tv/data/Program.java b/src/com/android/tv/data/Program.java index 30a3033e..f47a3a06 100644 --- a/src/com/android/tv/data/Program.java +++ b/src/com/android/tv/data/Program.java @@ -33,9 +33,8 @@ import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; import com.android.tv.common.BuildConfig; +import com.android.tv.common.CollectionUtils; import com.android.tv.common.TvContentRatingCache; -import com.android.tv.common.util.CollectionUtils; -import com.android.tv.common.util.CommonUtils; import com.android.tv.util.ImageLoader; import com.android.tv.util.Utils; import java.io.Serializable; @@ -129,7 +128,7 @@ public final class Program extends BaseProgram implements Comparable<Program>, P builder.setEndTimeUtcMillis(cursor.getLong(index++)); builder.setVideoWidth((int) cursor.getLong(index++)); builder.setVideoHeight((int) cursor.getLong(index++)); - if (CommonUtils.isInBundledPackageSet(packageName)) { + if (Utils.isInBundledPackageSet(packageName)) { InternalDataUtils.deserializeInternalProviderData(cursor.getBlob(index), builder); } index++; @@ -476,9 +475,6 @@ public final class Program extends BaseProgram implements Comparable<Program>, P public static ContentValues toContentValues(Program program) { ContentValues values = new ContentValues(); values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId()); - if (!TextUtils.isEmpty(program.getPackageName())) { - values.put(Programs.COLUMN_PACKAGE_NAME, program.getPackageName()); - } putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle()); putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { diff --git a/src/com/android/tv/data/ProgramDataManager.java b/src/com/android/tv/data/ProgramDataManager.java index 3a7693a4..639ac99a 100644 --- a/src/com/android/tv/data/ProgramDataManager.java +++ b/src/com/android/tv/data/ProgramDataManager.java @@ -35,8 +35,8 @@ import android.util.LongSparseArray; import android.util.LruCache; import com.android.tv.common.MemoryManageable; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.Clock; import com.android.tv.util.AsyncDbTask; +import com.android.tv.util.Clock; import com.android.tv.util.MultiLongSparseArray; import com.android.tv.util.Utils; import java.util.ArrayList; @@ -52,7 +52,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @MainThread -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class ProgramDataManager implements MemoryManageable { private static final String TAG = "ProgramDataManager"; private static final boolean DEBUG = false; diff --git a/src/com/android/tv/data/WatchedHistoryManager.java b/src/com/android/tv/data/WatchedHistoryManager.java index 25ba7716..8c9756b0 100644 --- a/src/com/android/tv/data/WatchedHistoryManager.java +++ b/src/com/android/tv/data/WatchedHistoryManager.java @@ -1,18 +1,3 @@ -/* - * 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.data; import android.content.Context; @@ -27,7 +12,7 @@ import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; import android.util.Log; -import com.android.tv.common.util.SharedPreferencesUtils; +import com.android.tv.common.SharedPreferencesUtils; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -43,7 +28,6 @@ import java.util.concurrent.TimeUnit; * * <p>Note that this class is not thread safe. Please use this on one thread. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class WatchedHistoryManager { private static final String TAG = "WatchedHistoryManager"; private static final boolean DEBUG = false; diff --git a/src/com/android/tv/data/epg/AutoValue_EpgReader_EpgChannel.java b/src/com/android/tv/data/epg/AutoValue_EpgReader_EpgChannel.java deleted file mode 100644 index 90d109d7..00000000 --- a/src/com/android/tv/data/epg/AutoValue_EpgReader_EpgChannel.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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.data.epg; - -import com.android.tv.data.Channel; - -/** - * Hand copy of generated Autovalue class. - * - * TODO get autovalue working - */ -final class AutoValue_EpgReader_EpgChannel extends EpgReader.EpgChannel { - - private final Channel channel; - private final String epgChannelId; - - AutoValue_EpgReader_EpgChannel( - Channel channel, - String epgChannelId) { - if (channel == null) { - throw new NullPointerException("Null channel"); - } - this.channel = channel; - if (epgChannelId == null) { - throw new NullPointerException("Null epgChannelId"); - } - this.epgChannelId = epgChannelId; - } - - @Override - public Channel getChannel() { - return channel; - } - - @Override - public String getEpgChannelId() { - return epgChannelId; - } - - @Override - public String toString() { - return "EpgChannel{" - + "channel=" + channel + ", " - + "epgChannelId=" + epgChannelId - + "}"; - } - - @Override - public boolean equals(Object o) { - if (o == this) { - return true; - } - if (o instanceof EpgReader.EpgChannel) { - EpgReader.EpgChannel that = (EpgReader.EpgChannel) o; - return (this.channel.equals(that.getChannel())) - && (this.epgChannelId.equals(that.getEpgChannelId())); - } - return false; - } - - @Override - public int hashCode() { - int h = 1; - h *= 1000003; - h ^= this.channel.hashCode(); - h *= 1000003; - h ^= this.epgChannelId.hashCode(); - return h; - } - -} - diff --git a/src/com/android/tv/data/epg/EpgFetchHelper.java b/src/com/android/tv/data/epg/EpgFetchHelper.java index 30123ee5..89d5f494 100644 --- a/src/com/android/tv/data/epg/EpgFetchHelper.java +++ b/src/com/android/tv/data/epg/EpgFetchHelper.java @@ -27,15 +27,13 @@ import android.preference.PreferenceManager; import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.util.Log; -import com.android.tv.common.util.Clock; import com.android.tv.data.Program; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; -/** The helper class for {@link EpgFetcher} */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed +/** The helper class for {@link com.android.tv.data.epg.EpgFetcher} */ class EpgFetchHelper { private static final String TAG = "EpgFetchHelper"; private static final boolean DEBUG = false; @@ -66,14 +64,13 @@ class EpgFetchHelper { * @param fetchedPrograms the newly fetched program data. * @return {@code true} if new program data are successfully updated. Otherwise {@code false}. */ - static boolean updateEpgData( - Context context, Clock clock, long channelId, List<Program> fetchedPrograms) { + static boolean updateEpgData(Context context, long channelId, List<Program> fetchedPrograms) { final int fetchedProgramsCount = fetchedPrograms.size(); if (fetchedProgramsCount == 0) { return false; } boolean updated = false; - long startTimeMs = clock.currentTimeMillis(); + long startTimeMs = System.currentTimeMillis(); long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION_MS; List<Program> oldPrograms = queryPrograms(context, channelId, startTimeMs, endTimeMs); int oldProgramsIndex = 0; diff --git a/src/com/android/tv/data/epg/EpgFetchService.java b/src/com/android/tv/data/epg/EpgFetchService.java deleted file mode 100644 index aa4f3588..00000000 --- a/src/com/android/tv/data/epg/EpgFetchService.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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.data.epg; - -import android.app.job.JobParameters; -import android.app.job.JobService; -import com.android.tv.Starter; -import com.android.tv.TvSingletons; -import com.android.tv.data.ChannelDataManager; - -/** JobService to Fetch EPG data. */ -public class EpgFetchService extends JobService { - private EpgFetcher mEpgFetcher; - private ChannelDataManager mChannelDataManager; - - @Override - public void onCreate() { - super.onCreate(); - Starter.start(this); - TvSingletons tvSingletons = TvSingletons.getSingletons(getApplicationContext()); - mEpgFetcher = tvSingletons.getEpgFetcher(); - mChannelDataManager = tvSingletons.getChannelDataManager(); - } - - @Override - public boolean onStartJob(JobParameters params) { - if (!mChannelDataManager.isDbLoadFinished()) { - mChannelDataManager.addListener( - new ChannelDataManager.Listener() { - @Override - public void onLoadFinished() { - mChannelDataManager.removeListener(this); - if (!mEpgFetcher.executeFetchTaskIfPossible( - EpgFetchService.this, params)) { - jobFinished(params, false); - } - } - - @Override - public void onChannelListUpdated() {} - - @Override - public void onChannelBrowsableChanged() {} - }); - return true; - } else { - return mEpgFetcher.executeFetchTaskIfPossible(this, params); - } - } - - @Override - public boolean onStopJob(JobParameters params) { - mEpgFetcher.stopFetchingJob(); - return false; - } -} diff --git a/src/com/android/tv/data/epg/EpgFetcher.java b/src/com/android/tv/data/epg/EpgFetcher.java index 9c24613d..b10bdc1b 100644 --- a/src/com/android/tv/data/epg/EpgFetcher.java +++ b/src/com/android/tv/data/epg/EpgFetcher.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 The Android Open Source Project + * 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. @@ -16,13 +16,138 @@ package com.android.tv.data.epg; +import android.app.job.JobInfo; import android.app.job.JobParameters; import android.app.job.JobScheduler; import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.media.tv.TvInputInfo; +import android.net.TrafficStats; +import android.os.AsyncTask; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; +import android.util.Log; +import com.android.tv.ApplicationSingletons; +import com.android.tv.Features; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonUtils; +import com.android.tv.config.RemoteConfigUtils; +import com.android.tv.data.Channel; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.data.ChannelLogoFetcher; +import com.android.tv.data.Lineup; +import com.android.tv.data.Program; +import com.android.tv.perf.EventNames; +import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.TimerEvent; +import com.android.tv.tuner.util.PostalCodeUtils; +import com.android.tv.util.LocationUtils; +import com.android.tv.util.NetworkTrafficTags; +import com.android.tv.util.Utils; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; -/** Fetch EPG routinely or on-demand during channel scanning */ -public interface EpgFetcher { +/** + * The service class to fetch EPG routinely or on-demand during channel scanning + * + * <p>Since the default executor of {@link AsyncTask} is {@link AsyncTask#SERIAL_EXECUTOR}, only one + * task can run at a time. Because fetching EPG takes long time, the fetching task shouldn't run on + * the serial executor. Instead, it should run on the {@link AsyncTask#THREAD_POOL_EXECUTOR}. + */ +public class EpgFetcher { + private static final String TAG = "EpgFetcher"; + private static final boolean DEBUG = false; + + private static final int EPG_ROUTINELY_FETCHING_JOB_ID = 101; + + private static final long INITIAL_BACKOFF_MS = TimeUnit.SECONDS.toMillis(10); + + private static final int REASON_EPG_READER_NOT_READY = 1; + private static final int REASON_LOCATION_INFO_UNAVAILABLE = 2; + private static final int REASON_LOCATION_PERMISSION_NOT_GRANTED = 3; + private static final int REASON_NO_EPG_DATA_RETURNED = 4; + private static final int REASON_NO_NEW_EPG = 5; + + private static final long FETCH_DURING_SCAN_WAIT_TIME_MS = TimeUnit.SECONDS.toMillis(10); + + private static final long FETCH_DURING_SCAN_DURATION_SEC = TimeUnit.HOURS.toSeconds(3); + private static final long FAST_FETCH_DURATION_SEC = TimeUnit.DAYS.toSeconds(2); + + private static final int DEFAULT_ROUTINE_INTERVAL_HOUR = 4; + private static final String KEY_ROUTINE_INTERVAL = "live_channels_epg_fetcher_interval_hour"; + + private static final int MSG_PREPARE_FETCH_DURING_SCAN = 1; + private static final int MSG_CHANNEL_UPDATED_DURING_SCAN = 2; + private static final int MSG_FINISH_FETCH_DURING_SCAN = 3; + private static final int MSG_RETRY_PREPARE_FETCH_DURING_SCAN = 4; + + private static final int QUERY_CHANNEL_COUNT = 50; + private static final int MINIMUM_CHANNELS_TO_DECIDE_LINEUP = 3; + + private static EpgFetcher sInstance; + + private final Context mContext; + private final ChannelDataManager mChannelDataManager; + private final EpgReader mEpgReader; + private final PerformanceMonitor mPerformanceMonitor; + private FetchAsyncTask mFetchTask; + private FetchDuringScanHandler mFetchDuringScanHandler; + private long mEpgTimeStamp; + private List<Lineup> mPossibleLineups; + private final Object mPossibleLineupsLock = new Object(); + private final Object mFetchDuringScanHandlerLock = new Object(); + // A flag to block the re-entrance of onChannelScanStarted and onChannelScanFinished. + private boolean mScanStarted; + + private final long mRoutineIntervalMs; + private final long mEpgDataExpiredTimeLimitMs; + private final long mFastFetchDurationSec; + + public static EpgFetcher getInstance(Context context) { + if (sInstance == null) { + sInstance = new EpgFetcher(context); + } + return sInstance; + } + + /** Creates and returns {@link EpgReader}. */ + public static EpgReader createEpgReader(Context context, String region) { + return new StubEpgReader(context); + } + + private EpgFetcher(Context context) { + mContext = context.getApplicationContext(); + ApplicationSingletons applicationSingletons = TvApplication.getSingletons(mContext); + mChannelDataManager = applicationSingletons.getChannelDataManager(); + mPerformanceMonitor = applicationSingletons.getPerformanceMonitor(); + mEpgReader = createEpgReader(mContext, LocationUtils.getCurrentCountry(mContext)); + + int remoteInteval = + (int) + RemoteConfigUtils.getRemoteConfig( + context, KEY_ROUTINE_INTERVAL, DEFAULT_ROUTINE_INTERVAL_HOUR); + mRoutineIntervalMs = + remoteInteval < 0 + ? TimeUnit.HOURS.toMillis(DEFAULT_ROUTINE_INTERVAL_HOUR) + : TimeUnit.HOURS.toMillis(remoteInteval); + mEpgDataExpiredTimeLimitMs = mRoutineIntervalMs * 2; + mFastFetchDurationSec = FAST_FETCH_DURATION_SEC + mRoutineIntervalMs / 1000; + } /** * Starts the routine service of EPG fetching. It use {@link JobScheduler} to schedule the EPG @@ -30,30 +155,590 @@ public interface EpgFetcher { * channel scanning of tuner input is started. */ @MainThread - void startRoutineService(); + public void startRoutineService() { + JobScheduler jobScheduler = + (JobScheduler) mContext.getSystemService(Context.JOB_SCHEDULER_SERVICE); + for (JobInfo job : jobScheduler.getAllPendingJobs()) { + if (job.getId() == EPG_ROUTINELY_FETCHING_JOB_ID) { + return; + } + } + JobInfo job = + new JobInfo.Builder( + EPG_ROUTINELY_FETCHING_JOB_ID, + new ComponentName(mContext, EpgFetchService.class)) + .setPeriodic(mRoutineIntervalMs) + .setBackoffCriteria(INITIAL_BACKOFF_MS, JobInfo.BACKOFF_POLICY_EXPONENTIAL) + .setPersisted(true) + .build(); + jobScheduler.schedule(job); + Log.i(TAG, "EPG fetching routine service started."); + } /** * Fetches EPG immediately if current EPG data are out-dated, i.e., not successfully updated by * routine fetching service due to various reasons. */ @MainThread - void fetchImmediatelyIfNeeded(); + public void fetchImmediatelyIfNeeded() { + if (TvCommonUtils.isRunningInTest()) { + // Do not run EpgFetcher in test. + return; + } + new AsyncTask<Void, Void, Long>() { + @Override + protected Long doInBackground(Void... args) { + return EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext); + } + + @Override + protected void onPostExecute(Long result) { + if (System.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) + > mEpgDataExpiredTimeLimitMs) { + Log.i(TAG, "EPG data expired. Start fetching immediately."); + fetchImmediately(); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } /** Fetches EPG immediately. */ @MainThread - void fetchImmediately(); + public void fetchImmediately() { + if (!mChannelDataManager.isDbLoadFinished()) { + mChannelDataManager.addListener( + new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + mChannelDataManager.removeListener(this); + executeFetchTaskIfPossible(null, null); + } + + @Override + public void onChannelListUpdated() {} + + @Override + public void onChannelBrowsableChanged() {} + }); + } else { + executeFetchTaskIfPossible(null, null); + } + } /** Notifies EPG fetch service that channel scanning is started. */ @MainThread - void onChannelScanStarted(); + public void onChannelScanStarted() { + if (mScanStarted || !Features.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { + return; + } + mScanStarted = true; + stopFetchingJob(); + synchronized (mFetchDuringScanHandlerLock) { + if (mFetchDuringScanHandler == null) { + HandlerThread thread = new HandlerThread("EpgFetchDuringScan"); + thread.start(); + mFetchDuringScanHandler = new FetchDuringScanHandler(thread.getLooper()); + } + mFetchDuringScanHandler.sendEmptyMessage(MSG_PREPARE_FETCH_DURING_SCAN); + } + Log.i(TAG, "EPG fetching on channel scanning started."); + } /** Notifies EPG fetch service that channel scanning is finished. */ @MainThread - void onChannelScanFinished(); + public void onChannelScanFinished() { + if (!mScanStarted) { + return; + } + mScanStarted = false; + mFetchDuringScanHandler.sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); + } @MainThread - boolean executeFetchTaskIfPossible(JobService jobService, JobParameters params); + private void stopFetchingJob() { + if (DEBUG) Log.d(TAG, "Try to stop routinely fetching job..."); + if (mFetchTask != null) { + mFetchTask.cancel(true); + mFetchTask = null; + Log.i(TAG, "EPG routinely fetching job stopped."); + } + } @MainThread - void stopFetchingJob(); + private boolean executeFetchTaskIfPossible(JobService service, JobParameters params) { + SoftPreconditions.checkState(mChannelDataManager.isDbLoadFinished()); + if (!TvCommonUtils.isRunningInTest() && checkFetchPrerequisite()) { + mFetchTask = new FetchAsyncTask(service, params); + mFetchTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + return true; + } + return false; + } + + @MainThread + private boolean checkFetchPrerequisite() { + if (DEBUG) Log.d(TAG, "Check prerequisite of routinely fetching job."); + if (!Features.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { + Log.i( + TAG, + "Cannot start routine service: country not supported: " + + LocationUtils.getCurrentCountry(mContext)); + return false; + } + if (mFetchTask != null) { + // Fetching job is already running or ready to run, no need to start again. + return false; + } + if (mFetchDuringScanHandler != null) { + if (DEBUG) Log.d(TAG, "Cannot start routine service: scanning channels."); + return false; + } + if (getTunerChannelCount() == 0) { + if (DEBUG) Log.d(TAG, "Cannot start routine service: no internal tuner channels."); + return false; + } + if (!TextUtils.isEmpty(EpgFetchHelper.getLastLineupId(mContext))) { + return true; + } + if (!TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return true; + } + return true; + } + + @MainThread + private int getTunerChannelCount() { + for (TvInputInfo input : + TvApplication.getSingletons(mContext) + .getTvInputManagerHelper() + .getTvInputInfos(true, true)) { + String inputId = input.getId(); + if (Utils.isInternalTvInput(mContext, inputId)) { + return mChannelDataManager.getChannelCountForInput(inputId); + } + } + return 0; + } + + @AnyThread + private void clearUnusedLineups(@Nullable String lineupId) { + synchronized (mPossibleLineupsLock) { + if (mPossibleLineups == null) { + return; + } + for (Lineup lineup : mPossibleLineups) { + if (!TextUtils.equals(lineupId, lineup.id)) { + mEpgReader.clearCachedChannels(lineup.id); + } + } + mPossibleLineups = null; + } + } + + @WorkerThread + private Integer prepareFetchEpg(boolean forceUpdatePossibleLineups) { + if (!mEpgReader.isAvailable()) { + Log.i(TAG, "EPG reader is temporarily unavailable."); + return REASON_EPG_READER_NOT_READY; + } + // Checks the EPG Timestamp. + mEpgTimeStamp = mEpgReader.getEpgTimestamp(); + if (mEpgTimeStamp <= EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext)) { + if (DEBUG) Log.d(TAG, "No new EPG."); + return REASON_NO_NEW_EPG; + } + // Updates postal code. + boolean postalCodeChanged = false; + try { + postalCodeChanged = PostalCodeUtils.updatePostalCode(mContext); + } catch (IOException e) { + if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); + if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return REASON_LOCATION_INFO_UNAVAILABLE; + } + } catch (SecurityException e) { + Log.w(TAG, "No permission to get the current location."); + if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return REASON_LOCATION_PERMISSION_NOT_GRANTED; + } + } catch (PostalCodeUtils.NoPostalCodeException e) { + Log.i(TAG, "Cannot get address or postal code."); + return REASON_LOCATION_INFO_UNAVAILABLE; + } + // Updates possible lineups if necessary. + SoftPreconditions.checkState(mPossibleLineups == null, TAG, "Possible lineups not reset."); + if (postalCodeChanged + || forceUpdatePossibleLineups + || EpgFetchHelper.getLastLineupId(mContext) == null) { + // To prevent main thread being blocked, though theoretically it should not happen. + List<Lineup> possibleLineups = + mEpgReader.getLineups(PostalCodeUtils.getLastPostalCode(mContext)); + if (possibleLineups.isEmpty()) { + return REASON_NO_EPG_DATA_RETURNED; + } + for (Lineup lineup : possibleLineups) { + mEpgReader.preloadChannels(lineup.id); + } + synchronized (mPossibleLineupsLock) { + mPossibleLineups = possibleLineups; + } + EpgFetchHelper.setLastLineupId(mContext, null); + } + return null; + } + + @WorkerThread + private void batchFetchEpg(List<Channel> channels, long durationSec) { + Log.i(TAG, "Start batch fetching (" + durationSec + ")...." + channels.size()); + if (channels.size() == 0) { + return; + } + List<Long> queryChannelIds = new ArrayList<>(QUERY_CHANNEL_COUNT); + for (Channel channel : channels) { + queryChannelIds.add(channel.getId()); + if (queryChannelIds.size() >= QUERY_CHANNEL_COUNT) { + batchUpdateEpg(mEpgReader.getPrograms(queryChannelIds, durationSec)); + queryChannelIds.clear(); + } + } + if (!queryChannelIds.isEmpty()) { + batchUpdateEpg(mEpgReader.getPrograms(queryChannelIds, durationSec)); + } + } + + @WorkerThread + private void batchUpdateEpg(Map<Long, List<Program>> allPrograms) { + for (Map.Entry<Long, List<Program>> entry : allPrograms.entrySet()) { + List<Program> programs = entry.getValue(); + if (programs == null) { + continue; + } + Collections.sort(programs); + Log.i( + TAG, + "Batch fetched " + programs.size() + " programs for channel " + entry.getKey()); + EpgFetchHelper.updateEpgData(mContext, entry.getKey(), programs); + } + } + + @Nullable + @WorkerThread + private String pickBestLineupId(List<Channel> currentChannelList) { + String maxLineupId = null; + synchronized (mPossibleLineupsLock) { + if (mPossibleLineups == null) { + return null; + } + int maxCount = 0; + for (Lineup lineup : mPossibleLineups) { + int count = getMatchedChannelCount(lineup.id, currentChannelList); + Log.i(TAG, lineup.name + " (" + lineup.id + ") - " + count + " matches"); + if (count > maxCount) { + maxCount = count; + maxLineupId = lineup.id; + } + } + } + return maxLineupId; + } + + @WorkerThread + private int getMatchedChannelCount(String lineupId, List<Channel> currentChannelList) { + // Construct a list of display numbers for existing channels. + if (currentChannelList.isEmpty()) { + if (DEBUG) Log.d(TAG, "No existing channel to compare"); + return 0; + } + List<String> numbers = new ArrayList<>(currentChannelList.size()); + for (Channel channel : currentChannelList) { + // We only support channels from internal tuner inputs. + if (Utils.isInternalTvInput(mContext, channel.getInputId())) { + numbers.add(channel.getDisplayNumber()); + } + } + numbers.retainAll(mEpgReader.getChannelNumbers(lineupId)); + return numbers.size(); + } + + public static class EpgFetchService extends JobService { + private EpgFetcher mEpgFetcher; + + @Override + public void onCreate() { + super.onCreate(); + TvApplication.setCurrentRunningProcess(this, true); + mEpgFetcher = EpgFetcher.getInstance(this); + } + + @Override + public boolean onStartJob(JobParameters params) { + if (!mEpgFetcher.mChannelDataManager.isDbLoadFinished()) { + mEpgFetcher.mChannelDataManager.addListener( + new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + mEpgFetcher.mChannelDataManager.removeListener(this); + if (!mEpgFetcher.executeFetchTaskIfPossible( + EpgFetchService.this, params)) { + jobFinished(params, false); + } + } + + @Override + public void onChannelListUpdated() {} + + @Override + public void onChannelBrowsableChanged() {} + }); + return true; + } else { + return mEpgFetcher.executeFetchTaskIfPossible(this, params); + } + } + + @Override + public boolean onStopJob(JobParameters params) { + mEpgFetcher.stopFetchingJob(); + return false; + } + } + + private class FetchAsyncTask extends AsyncTask<Void, Void, Integer> { + private final JobService mService; + private final JobParameters mParams; + private List<Channel> mCurrentChannelList; + private TimerEvent mTimerEvent; + + private FetchAsyncTask(JobService service, JobParameters params) { + mService = service; + mParams = params; + } + + @Override + protected void onPreExecute() { + mTimerEvent = mPerformanceMonitor.startTimer(); + mCurrentChannelList = mChannelDataManager.getChannelList(); + } + + @Override + protected Integer doInBackground(Void... args) { + final int oldTag = TrafficStats.getThreadStatsTag(); + TrafficStats.setThreadStatsTag(NetworkTrafficTags.EPG_FETCH); + try { + if (DEBUG) Log.d(TAG, "Start EPG routinely fetching."); + Integer failureReason = prepareFetchEpg(false); + // InterruptedException might be caught by RPC, we should check it here. + if (failureReason != null || this.isCancelled()) { + return failureReason; + } + String lineupId = EpgFetchHelper.getLastLineupId(mContext); + lineupId = lineupId == null ? pickBestLineupId(mCurrentChannelList) : lineupId; + if (lineupId != null) { + Log.i(TAG, "Selecting the lineup " + lineupId); + // During normal fetching process, the lineup ID should be confirmed since all + // channels are known, clear up possible lineups to save resources. + EpgFetchHelper.setLastLineupId(mContext, lineupId); + clearUnusedLineups(lineupId); + } else { + Log.i(TAG, "Failed to get lineup id"); + return REASON_NO_EPG_DATA_RETURNED; + } + final List<Channel> channels = mEpgReader.getChannels(lineupId); + // InterruptedException might be caught by RPC, we should check it here. + if (this.isCancelled()) { + return null; + } + if (channels.isEmpty()) { + Log.i(TAG, "Failed to get EPG channels."); + return REASON_NO_EPG_DATA_RETURNED; + } + if (System.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) + > mEpgDataExpiredTimeLimitMs) { + batchFetchEpg(channels, mFastFetchDurationSec); + } + new Handler(mContext.getMainLooper()) + .post( + new Runnable() { + @Override + public void run() { + ChannelLogoFetcher.startFetchingChannelLogos( + mContext, channels); + } + }); + for (Channel channel : channels) { + if (this.isCancelled()) { + return null; + } + long channelId = channel.getId(); + List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channelId)); + // InterruptedException might be caught by RPC, we should check it here. + Collections.sort(programs); + Log.i(TAG, "Fetched " + programs.size() + " programs for channel " + channelId); + EpgFetchHelper.updateEpgData(mContext, channelId, programs); + } + EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, mEpgTimeStamp); + if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); + return null; + } finally { + TrafficStats.setThreadStatsTag(oldTag); + } + } + + @Override + protected void onPostExecute(Integer failureReason) { + mFetchTask = null; + if (failureReason == null + || failureReason == REASON_LOCATION_PERMISSION_NOT_GRANTED + || failureReason == REASON_NO_NEW_EPG) { + jobFinished(false); + } else { + // Applies back-off policy + jobFinished(true); + } + mPerformanceMonitor.stopTimer(mTimerEvent, EventNames.FETCH_EPG_TASK); + mPerformanceMonitor.recordMemory(EventNames.FETCH_EPG_TASK); + } + + @Override + protected void onCancelled(Integer failureReason) { + clearUnusedLineups(null); + jobFinished(false); + } + + private void jobFinished(boolean reschedule) { + if (mService != null && mParams != null) { + // Task is executed from JobService, need to report jobFinished. + mService.jobFinished(mParams, reschedule); + } + } + } + + @WorkerThread + private class FetchDuringScanHandler extends Handler { + private final Set<Long> mFetchedChannelIdsDuringScan = new HashSet<>(); + private String mPossibleLineupId; + + private final ChannelDataManager.Listener mDuringScanChannelListener = + new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); + if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP + && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + Message.obtain( + FetchDuringScanHandler.this, + MSG_CHANNEL_UPDATED_DURING_SCAN, + new ArrayList<>(mChannelDataManager.getChannelList())) + .sendToTarget(); + } + } + + @Override + public void onChannelListUpdated() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); + if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP + && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + Message.obtain( + FetchDuringScanHandler.this, + MSG_CHANNEL_UPDATED_DURING_SCAN, + mChannelDataManager.getChannelList()) + .sendToTarget(); + } + } + + @Override + public void onChannelBrowsableChanged() { + // Do nothing + } + }; + + @AnyThread + private FetchDuringScanHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MSG_PREPARE_FETCH_DURING_SCAN: + case MSG_RETRY_PREPARE_FETCH_DURING_SCAN: + onPrepareFetchDuringScan(); + break; + case MSG_CHANNEL_UPDATED_DURING_SCAN: + if (!hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + onChannelUpdatedDuringScan((List<Channel>) msg.obj); + } + break; + case MSG_FINISH_FETCH_DURING_SCAN: + removeMessages(MSG_RETRY_PREPARE_FETCH_DURING_SCAN); + if (hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); + } else { + onFinishFetchDuringScan(); + } + break; + } + } + + private void onPrepareFetchDuringScan() { + Integer failureReason = prepareFetchEpg(true); + if (failureReason != null) { + sendEmptyMessageDelayed( + MSG_RETRY_PREPARE_FETCH_DURING_SCAN, FETCH_DURING_SCAN_WAIT_TIME_MS); + return; + } + mChannelDataManager.addListener(mDuringScanChannelListener); + } + + private void onChannelUpdatedDuringScan(List<Channel> currentChannelList) { + String lineupId = pickBestLineupId(currentChannelList); + Log.i(TAG, "Fast fetch channels for lineup ID: " + lineupId); + if (TextUtils.isEmpty(lineupId)) { + if (TextUtils.isEmpty(mPossibleLineupId)) { + return; + } + } else if (!TextUtils.equals(lineupId, mPossibleLineupId)) { + mFetchedChannelIdsDuringScan.clear(); + mPossibleLineupId = lineupId; + } + List<Long> currentChannelIds = new ArrayList<>(); + for (Channel channel : currentChannelList) { + currentChannelIds.add(channel.getId()); + } + mFetchedChannelIdsDuringScan.retainAll(currentChannelIds); + List<Channel> newChannels = new ArrayList<>(); + for (Channel channel : mEpgReader.getChannels(mPossibleLineupId)) { + if (!mFetchedChannelIdsDuringScan.contains(channel.getId())) { + newChannels.add(channel); + mFetchedChannelIdsDuringScan.add(channel.getId()); + } + } + batchFetchEpg(newChannels, FETCH_DURING_SCAN_DURATION_SEC); + } + + private void onFinishFetchDuringScan() { + mChannelDataManager.removeListener(mDuringScanChannelListener); + EpgFetchHelper.setLastLineupId(mContext, mPossibleLineupId); + clearUnusedLineups(null); + mFetchedChannelIdsDuringScan.clear(); + synchronized (mFetchDuringScanHandlerLock) { + if (!hasMessages(MSG_PREPARE_FETCH_DURING_SCAN)) { + removeCallbacksAndMessages(null); + getLooper().quit(); + mFetchDuringScanHandler = null; + } + } + // Clear timestamp to make routine service start right away. + EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, 0); + Log.i(TAG, "EPG Fetching during channel scanning finished."); + new Handler(Looper.getMainLooper()) + .post( + new Runnable() { + @Override + public void run() { + fetchImmediately(); + } + }); + } + } } diff --git a/src/com/android/tv/data/epg/EpgFetcherImpl.java b/src/com/android/tv/data/epg/EpgFetcherImpl.java deleted file mode 100644 index 523fc50c..00000000 --- a/src/com/android/tv/data/epg/EpgFetcherImpl.java +++ /dev/null @@ -1,814 +0,0 @@ -/* - * 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.data.epg; - -import android.app.job.JobInfo; -import android.app.job.JobParameters; -import android.app.job.JobScheduler; -import android.app.job.JobService; -import android.content.ComponentName; -import android.content.Context; -import android.database.Cursor; -import android.media.tv.TvContract; -import android.media.tv.TvInputInfo; -import android.net.TrafficStats; -import android.os.AsyncTask; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Looper; -import android.os.Message; -import android.support.annotation.AnyThread; -import android.support.annotation.MainThread; -import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; -import android.support.annotation.WorkerThread; -import android.text.TextUtils; -import android.util.Log; -import com.android.tv.TvFeatures; -import com.android.tv.TvSingletons; -import com.android.tv.common.BuildConfig; -import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.config.RemoteConfigUtils; -import com.android.tv.common.util.Clock; -import com.android.tv.common.util.CommonUtils; -import com.android.tv.common.util.LocationUtils; -import com.android.tv.common.util.NetworkTrafficTags; -import com.android.tv.common.util.PermissionUtils; -import com.android.tv.common.util.PostalCodeUtils; -import com.android.tv.data.Channel; -import com.android.tv.data.ChannelDataManager; -import com.android.tv.data.ChannelLogoFetcher; -import com.android.tv.data.Lineup; -import com.android.tv.data.Program; - - -import com.android.tv.perf.EventNames; -import com.android.tv.perf.PerformanceMonitor; -import com.android.tv.perf.TimerEvent; -import com.android.tv.util.Utils; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -/** - * The service class to fetch EPG routinely or on-demand during channel scanning - * - * <p>Since the default executor of {@link AsyncTask} is {@link AsyncTask#SERIAL_EXECUTOR}, only one - * task can run at a time. Because fetching EPG takes long time, the fetching task shouldn't run on - * the serial executor. Instead, it should run on the {@link AsyncTask#THREAD_POOL_EXECUTOR}. - */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed -public class EpgFetcherImpl implements EpgFetcher { - private static final String TAG = "EpgFetcherImpl"; - private static final boolean DEBUG = false; - - private static final int EPG_ROUTINELY_FETCHING_JOB_ID = 101; - - private static final long INITIAL_BACKOFF_MS = TimeUnit.SECONDS.toMillis(10); - - @VisibleForTesting static final int REASON_EPG_READER_NOT_READY = 1; - @VisibleForTesting static final int REASON_LOCATION_INFO_UNAVAILABLE = 2; - @VisibleForTesting static final int REASON_LOCATION_PERMISSION_NOT_GRANTED = 3; - @VisibleForTesting static final int REASON_NO_EPG_DATA_RETURNED = 4; - @VisibleForTesting static final int REASON_NO_NEW_EPG = 5; - @VisibleForTesting static final int REASON_ERROR = 6; - @VisibleForTesting static final int REASON_CLOUD_EPG_FAILURE = 7; - @VisibleForTesting static final int REASON_NO_BUILT_IN_CHANNELS = 8; - - private static final long FETCH_DURING_SCAN_WAIT_TIME_MS = TimeUnit.SECONDS.toMillis(10); - - private static final long FETCH_DURING_SCAN_DURATION_SEC = TimeUnit.HOURS.toSeconds(3); - private static final long FAST_FETCH_DURATION_SEC = TimeUnit.DAYS.toSeconds(2); - - private static final int DEFAULT_ROUTINE_INTERVAL_HOUR = 4; - private static final String KEY_ROUTINE_INTERVAL = "live_channels_epg_fetcher_interval_hour"; - - private static final int MSG_PREPARE_FETCH_DURING_SCAN = 1; - private static final int MSG_CHANNEL_UPDATED_DURING_SCAN = 2; - private static final int MSG_FINISH_FETCH_DURING_SCAN = 3; - private static final int MSG_RETRY_PREPARE_FETCH_DURING_SCAN = 4; - - private static final int QUERY_CHANNEL_COUNT = 50; - private static final int MINIMUM_CHANNELS_TO_DECIDE_LINEUP = 3; - - private final Context mContext; - private final ChannelDataManager mChannelDataManager; - private final EpgReader mEpgReader; - private final PerformanceMonitor mPerformanceMonitor; - private FetchAsyncTask mFetchTask; - private FetchDuringScanHandler mFetchDuringScanHandler; - private long mEpgTimeStamp; - private List<Lineup> mPossibleLineups; - private final Object mPossibleLineupsLock = new Object(); - private final Object mFetchDuringScanHandlerLock = new Object(); - // A flag to block the re-entrance of onChannelScanStarted and onChannelScanFinished. - private boolean mScanStarted; - - private final long mRoutineIntervalMs; - private final long mEpgDataExpiredTimeLimitMs; - private final long mFastFetchDurationSec; - private Clock mClock; - - public static EpgFetcher create(Context context) { - context = context.getApplicationContext(); - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - ChannelDataManager channelDataManager = tvSingletons.getChannelDataManager(); - PerformanceMonitor performanceMonitor = tvSingletons.getPerformanceMonitor(); - EpgReader epgReader = tvSingletons.providesEpgReader().get(); - Clock clock = tvSingletons.getClock(); - int routineIntervalMs = - (int) - RemoteConfigUtils.getRemoteConfig( - context, KEY_ROUTINE_INTERVAL, DEFAULT_ROUTINE_INTERVAL_HOUR); - - return new EpgFetcherImpl( - context, - channelDataManager, - epgReader, - performanceMonitor, - clock, - routineIntervalMs); - } - - @VisibleForTesting - EpgFetcherImpl( - Context context, - ChannelDataManager channelDataManager, - EpgReader epgReader, - PerformanceMonitor performanceMonitor, - Clock clock, - long routineIntervalMs) { - mContext = context; - mChannelDataManager = channelDataManager; - mEpgReader = epgReader; - mPerformanceMonitor = performanceMonitor; - mClock = clock; - mRoutineIntervalMs = - routineIntervalMs <= 0 - ? TimeUnit.HOURS.toMillis(DEFAULT_ROUTINE_INTERVAL_HOUR) - : TimeUnit.HOURS.toMillis(routineIntervalMs); - mEpgDataExpiredTimeLimitMs = routineIntervalMs * 2; - mFastFetchDurationSec = FAST_FETCH_DURATION_SEC + routineIntervalMs / 1000; - } - - private static Set<Channel> getExistingChannelsForMyPackage(Context context) { - HashSet<Channel> channels = new HashSet<>(); - String selection = null; - String[] selectionArgs = null; - String myPackageName = context.getPackageName(); - if (PermissionUtils.hasAccessAllEpg(context)) { - selection = "package_name=?"; - selectionArgs = new String[] {myPackageName}; - } - try (Cursor c = - context.getContentResolver() - .query( - TvContract.Channels.CONTENT_URI, - Channel.PROJECTION, - selection, - selectionArgs, - null)) { - if (c != null) { - while (c.moveToNext()) { - Channel channel = Channel.fromCursor(c); - if (DEBUG) Log.d(TAG, "Found " + channel); - if (myPackageName.equals(channel.getPackageName())) { - channels.add(channel); - } - } - } - } - if (DEBUG) - Log.d(TAG, "Found " + channels.size() + " channels for package " + myPackageName); - return channels; - } - - @Override - @MainThread - public void startRoutineService() { - JobScheduler jobScheduler = - (JobScheduler) mContext.getSystemService(Context.JOB_SCHEDULER_SERVICE); - for (JobInfo job : jobScheduler.getAllPendingJobs()) { - if (job.getId() == EPG_ROUTINELY_FETCHING_JOB_ID) { - return; - } - } - JobInfo job = - new JobInfo.Builder( - EPG_ROUTINELY_FETCHING_JOB_ID, - new ComponentName(mContext, EpgFetchService.class)) - .setPeriodic(mRoutineIntervalMs) - .setBackoffCriteria(INITIAL_BACKOFF_MS, JobInfo.BACKOFF_POLICY_EXPONENTIAL) - .setPersisted(true) - .build(); - jobScheduler.schedule(job); - Log.i(TAG, "EPG fetching routine service started."); - } - - @Override - @MainThread - public void fetchImmediatelyIfNeeded() { - if (CommonUtils.isRunningInTest()) { - // Do not run EpgFetcher in test. - return; - } - new AsyncTask<Void, Void, Long>() { - @Override - protected Long doInBackground(Void... args) { - return EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext); - } - - @Override - protected void onPostExecute(Long result) { - if (mClock.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) - > mEpgDataExpiredTimeLimitMs) { - Log.i(TAG, "EPG data expired. Start fetching immediately."); - fetchImmediately(); - } - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - @Override - @MainThread - public void fetchImmediately() { - if (DEBUG) Log.d(TAG, "fetchImmediately"); - if (!mChannelDataManager.isDbLoadFinished()) { - mChannelDataManager.addListener( - new ChannelDataManager.Listener() { - @Override - public void onLoadFinished() { - mChannelDataManager.removeListener(this); - executeFetchTaskIfPossible(null, null); - } - - @Override - public void onChannelListUpdated() {} - - @Override - public void onChannelBrowsableChanged() {} - }); - } else { - executeFetchTaskIfPossible(null, null); - } - } - - @Override - @MainThread - public void onChannelScanStarted() { - if (mScanStarted || !TvFeatures.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { - return; - } - mScanStarted = true; - stopFetchingJob(); - synchronized (mFetchDuringScanHandlerLock) { - if (mFetchDuringScanHandler == null) { - HandlerThread thread = new HandlerThread("EpgFetchDuringScan"); - thread.start(); - mFetchDuringScanHandler = new FetchDuringScanHandler(thread.getLooper()); - } - mFetchDuringScanHandler.sendEmptyMessage(MSG_PREPARE_FETCH_DURING_SCAN); - } - Log.i(TAG, "EPG fetching on channel scanning started."); - } - - @Override - @MainThread - public void onChannelScanFinished() { - if (!mScanStarted) { - return; - } - mScanStarted = false; - mFetchDuringScanHandler.sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); - } - - @MainThread - @Override - public void stopFetchingJob() { - if (DEBUG) Log.d(TAG, "Try to stop routinely fetching job..."); - if (mFetchTask != null) { - mFetchTask.cancel(true); - mFetchTask = null; - Log.i(TAG, "EPG routinely fetching job stopped."); - } - } - - @MainThread - @Override - public boolean executeFetchTaskIfPossible(JobService service, JobParameters params) { - if (DEBUG) Log.d(TAG, "executeFetchTaskIfPossible"); - SoftPreconditions.checkState(mChannelDataManager.isDbLoadFinished()); - if (!CommonUtils.isRunningInTest() && checkFetchPrerequisite()) { - mFetchTask = createFetchTask(service, params); - mFetchTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - return true; - } - return false; - } - - @VisibleForTesting - FetchAsyncTask createFetchTask(JobService service, JobParameters params) { - return new FetchAsyncTask(service, params); - } - - @MainThread - private boolean checkFetchPrerequisite() { - if (DEBUG) Log.d(TAG, "Check prerequisite of routinely fetching job."); - if (!TvFeatures.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { - Log.i( - TAG, - "Cannot start routine service: country not supported: " - + LocationUtils.getCurrentCountry(mContext)); - return false; - } - if (mFetchTask != null) { - // Fetching job is already running or ready to run, no need to start again. - return false; - } - if (mFetchDuringScanHandler != null) { - if (DEBUG) Log.d(TAG, "Cannot start routine service: scanning channels."); - return false; - } - return true; - } - - @MainThread - private int getTunerChannelCount() { - for (TvInputInfo input : - TvSingletons.getSingletons(mContext) - .getTvInputManagerHelper() - .getTvInputInfos(true, true)) { - String inputId = input.getId(); - if (Utils.isInternalTvInput(mContext, inputId)) { - return mChannelDataManager.getChannelCountForInput(inputId); - } - } - return 0; - } - - @AnyThread - private void clearUnusedLineups(@Nullable String lineupId) { - synchronized (mPossibleLineupsLock) { - if (mPossibleLineups == null) { - return; - } - for (Lineup lineup : mPossibleLineups) { - if (!TextUtils.equals(lineupId, lineup.getId())) { - mEpgReader.clearCachedChannels(lineup.getId()); - } - } - mPossibleLineups = null; - } - } - - @WorkerThread - private Integer prepareFetchEpg(boolean forceUpdatePossibleLineups) { - if (!mEpgReader.isAvailable()) { - Log.i(TAG, "EPG reader is temporarily unavailable."); - return REASON_EPG_READER_NOT_READY; - } - // Checks the EPG Timestamp. - mEpgTimeStamp = mEpgReader.getEpgTimestamp(); - if (mEpgTimeStamp <= EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext)) { - if (DEBUG) Log.d(TAG, "No new EPG."); - return REASON_NO_NEW_EPG; - } - // Updates postal code. - boolean postalCodeChanged = false; - try { - postalCodeChanged = PostalCodeUtils.updatePostalCode(mContext); - } catch (IOException e) { - if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); - if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { - return REASON_LOCATION_INFO_UNAVAILABLE; - } - } catch (SecurityException e) { - Log.w(TAG, "No permission to get the current location."); - if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { - return REASON_LOCATION_PERMISSION_NOT_GRANTED; - } - } catch (PostalCodeUtils.NoPostalCodeException e) { - Log.i(TAG, "Cannot get address or postal code."); - return REASON_LOCATION_INFO_UNAVAILABLE; - } - // Updates possible lineups if necessary. - SoftPreconditions.checkState(mPossibleLineups == null, TAG, "Possible lineups not reset."); - if (postalCodeChanged - || forceUpdatePossibleLineups - || EpgFetchHelper.getLastLineupId(mContext) == null) { - // To prevent main thread being blocked, though theoretically it should not happen. - String lastPostalCode = PostalCodeUtils.getLastPostalCode(mContext); - List<Lineup> possibleLineups = mEpgReader.getLineups(lastPostalCode); - if (possibleLineups.isEmpty()) { - Log.i(TAG, "No lineups found for " + lastPostalCode); - return REASON_NO_EPG_DATA_RETURNED; - } - for (Lineup lineup : possibleLineups) { - mEpgReader.preloadChannels(lineup.getId()); - } - synchronized (mPossibleLineupsLock) { - mPossibleLineups = possibleLineups; - } - EpgFetchHelper.setLastLineupId(mContext, null); - } - return null; - } - - @WorkerThread - private void batchFetchEpg(Set<EpgReader.EpgChannel> epgChannels, long durationSec) { - Log.i(TAG, "Start batch fetching (" + durationSec + ")...." + epgChannels.size()); - if (epgChannels.size() == 0) { - return; - } - Set<EpgReader.EpgChannel> batch = new HashSet<>(QUERY_CHANNEL_COUNT); - for (EpgReader.EpgChannel epgChannel : epgChannels) { - batch.add(epgChannel); - if (batch.size() >= QUERY_CHANNEL_COUNT) { - batchUpdateEpg(mEpgReader.getPrograms(batch, durationSec)); - batch.clear(); - } - } - if (!batch.isEmpty()) { - batchUpdateEpg(mEpgReader.getPrograms(batch, durationSec)); - } - } - - @WorkerThread - private void batchUpdateEpg(Map<EpgReader.EpgChannel, Collection<Program>> allPrograms) { - for (Map.Entry<EpgReader.EpgChannel, Collection<Program>> entry : allPrograms.entrySet()) { - List<Program> programs = new ArrayList(entry.getValue()); - if (programs == null) { - continue; - } - Collections.sort(programs); - Log.i( - TAG, - "Batch fetched " + programs.size() + " programs for channel " + entry.getKey()); - EpgFetchHelper.updateEpgData( - mContext, mClock, entry.getKey().getChannel().getId(), programs); - } - } - - @Nullable - @WorkerThread - private String pickBestLineupId(Set<Channel> currentChannels) { - String maxLineupId = null; - synchronized (mPossibleLineupsLock) { - if (mPossibleLineups == null) { - return null; - } - int maxCount = 0; - for (Lineup lineup : mPossibleLineups) { - int count = getMatchedChannelCount(lineup.getId(), currentChannels); - Log.i(TAG, lineup.getName() + " (" + lineup.getId() + ") - " + count + " matches"); - if (count > maxCount) { - maxCount = count; - maxLineupId = lineup.getId(); - } - } - } - return maxLineupId; - } - - @WorkerThread - private int getMatchedChannelCount(String lineupId, Set<Channel> currentChannels) { - // Construct a list of display numbers for existing channels. - if (currentChannels.isEmpty()) { - if (DEBUG) Log.d(TAG, "No existing channel to compare"); - return 0; - } - List<String> numbers = new ArrayList<>(currentChannels.size()); - for (Channel channel : currentChannels) { - // We only support channels from internal tuner inputs. - if (Utils.isInternalTvInput(mContext, channel.getInputId())) { - numbers.add(channel.getDisplayNumber()); - } - } - numbers.retainAll(mEpgReader.getChannelNumbers(lineupId)); - return numbers.size(); - } - - @VisibleForTesting - class FetchAsyncTask extends AsyncTask<Void, Void, Integer> { - private final JobService mService; - private final JobParameters mParams; - private Set<Channel> mCurrentChannels; - private TimerEvent mTimerEvent; - - private FetchAsyncTask(JobService service, JobParameters params) { - mService = service; - mParams = params; - } - - @Override - protected void onPreExecute() { - mTimerEvent = mPerformanceMonitor.startTimer(); - mCurrentChannels = new HashSet<>(mChannelDataManager.getChannelList()); - } - - @Override - protected Integer doInBackground(Void... args) { - final int oldTag = TrafficStats.getThreadStatsTag(); - TrafficStats.setThreadStatsTag(NetworkTrafficTags.EPG_FETCH); - try { - if (DEBUG) Log.d(TAG, "Start EPG routinely fetching."); - Integer builtInResult = fetchEpgForBuiltInTuner(); - boolean anyCloudEpgFailure = false; - boolean anyCloudEpgSuccess = false; - return builtInResult; - } finally { - TrafficStats.setThreadStatsTag(oldTag); - } - } - - private Set<Channel> getExistingChannelsFor(String inputId) { - Set<Channel> result = new HashSet<>(); - try (Cursor cursor = - mContext.getContentResolver() - .query( - TvContract.buildChannelsUriForInput(inputId), - Channel.PROJECTION, - null, - null, - null)) { - while (cursor.moveToNext()) { - result.add(Channel.fromCursor(cursor)); - } - return result; - } - } - - private Integer fetchEpgForBuiltInTuner() { - try { - Integer failureReason = prepareFetchEpg(false); - // InterruptedException might be caught by RPC, we should check it here. - if (failureReason != null || this.isCancelled()) { - return failureReason; - } - String lineupId = EpgFetchHelper.getLastLineupId(mContext); - lineupId = lineupId == null ? pickBestLineupId(mCurrentChannels) : lineupId; - if (lineupId != null) { - Log.i(TAG, "Selecting the lineup " + lineupId); - // During normal fetching process, the lineup ID should be confirmed since all - // channels are known, clear up possible lineups to save resources. - EpgFetchHelper.setLastLineupId(mContext, lineupId); - clearUnusedLineups(lineupId); - } else { - Log.i(TAG, "Failed to get lineup id"); - return REASON_NO_EPG_DATA_RETURNED; - } - Set<Channel> existingChannelsForMyPackage = - getExistingChannelsForMyPackage(mContext); - if (existingChannelsForMyPackage.isEmpty()) { - return REASON_NO_BUILT_IN_CHANNELS; - } - return fetchEpgFor(lineupId, existingChannelsForMyPackage); - } catch (Exception e) { - Log.w(TAG, "Failed to update EPG for builtin tuner", e); - return REASON_ERROR; - } - } - - @Nullable - private Integer fetchEpgFor(String lineupId, Set<Channel> existingChannels) { - if (DEBUG) { - Log.d( - TAG, - "Starting Fetching EPG is for " - + lineupId - + " with channelCount " - + existingChannels.size()); - } - final Set<EpgReader.EpgChannel> channels = - mEpgReader.getChannels(existingChannels, lineupId); - // InterruptedException might be caught by RPC, we should check it here. - if (this.isCancelled()) { - return null; - } - if (channels.isEmpty()) { - Log.i(TAG, "Failed to get EPG channels for " + lineupId); - return REASON_NO_EPG_DATA_RETURNED; - } - if (mClock.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) - > mEpgDataExpiredTimeLimitMs) { - batchFetchEpg(channels, mFastFetchDurationSec); - } - new Handler(mContext.getMainLooper()) - .post( - new Runnable() { - @Override - public void run() { - ChannelLogoFetcher.startFetchingChannelLogos( - mContext, asChannelList(channels)); - } - }); - for (EpgReader.EpgChannel epgChannel : channels) { - if (this.isCancelled()) { - return null; - } - List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(epgChannel)); - // InterruptedException might be caught by RPC, we should check it here. - Collections.sort(programs); - Log.i( - TAG, - "Fetched " - + programs.size() - + " programs for channel " - + epgChannel.getChannel()); - EpgFetchHelper.updateEpgData( - mContext, mClock, epgChannel.getChannel().getId(), programs); - } - EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, mEpgTimeStamp); - if (DEBUG) Log.d(TAG, "Fetching EPG is for " + lineupId); - return null; - } - - @Override - protected void onPostExecute(Integer failureReason) { - mFetchTask = null; - if (failureReason == null - || failureReason == REASON_LOCATION_PERMISSION_NOT_GRANTED - || failureReason == REASON_NO_NEW_EPG) { - jobFinished(false); - } else { - // Applies back-off policy - jobFinished(true); - } - mPerformanceMonitor.stopTimer(mTimerEvent, EventNames.FETCH_EPG_TASK); - mPerformanceMonitor.recordMemory(EventNames.FETCH_EPG_TASK); - } - - @Override - protected void onCancelled(Integer failureReason) { - clearUnusedLineups(null); - jobFinished(false); - } - - private void jobFinished(boolean reschedule) { - if (mService != null && mParams != null) { - // Task is executed from JobService, need to report jobFinished. - mService.jobFinished(mParams, reschedule); - } - } - } - - private List<Channel> asChannelList(Set<EpgReader.EpgChannel> epgChannels) { - List<Channel> result = new ArrayList<>(epgChannels.size()); - for (EpgReader.EpgChannel epgChannel : epgChannels) { - result.add(epgChannel.getChannel()); - } - return result; - } - - @WorkerThread - private class FetchDuringScanHandler extends Handler { - private final Set<Long> mFetchedChannelIdsDuringScan = new HashSet<>(); - private String mPossibleLineupId; - - private final ChannelDataManager.Listener mDuringScanChannelListener = - new ChannelDataManager.Listener() { - @Override - public void onLoadFinished() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); - if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP - && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { - Message.obtain( - FetchDuringScanHandler.this, - MSG_CHANNEL_UPDATED_DURING_SCAN, - getExistingChannelsForMyPackage(mContext)) - .sendToTarget(); - } - } - - @Override - public void onChannelListUpdated() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); - if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP - && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { - Message.obtain( - FetchDuringScanHandler.this, - MSG_CHANNEL_UPDATED_DURING_SCAN, - getExistingChannelsForMyPackage(mContext)) - .sendToTarget(); - } - } - - @Override - public void onChannelBrowsableChanged() { - // Do nothing - } - }; - - @AnyThread - private FetchDuringScanHandler(Looper looper) { - super(looper); - } - - @Override - public void handleMessage(Message msg) { - switch (msg.what) { - case MSG_PREPARE_FETCH_DURING_SCAN: - case MSG_RETRY_PREPARE_FETCH_DURING_SCAN: - onPrepareFetchDuringScan(); - break; - case MSG_CHANNEL_UPDATED_DURING_SCAN: - if (!hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { - onChannelUpdatedDuringScan((Set<Channel>) msg.obj); - } - break; - case MSG_FINISH_FETCH_DURING_SCAN: - removeMessages(MSG_RETRY_PREPARE_FETCH_DURING_SCAN); - if (hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { - sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); - } else { - onFinishFetchDuringScan(); - } - break; - default: - // do nothing - } - } - - private void onPrepareFetchDuringScan() { - Integer failureReason = prepareFetchEpg(true); - if (failureReason != null) { - sendEmptyMessageDelayed( - MSG_RETRY_PREPARE_FETCH_DURING_SCAN, FETCH_DURING_SCAN_WAIT_TIME_MS); - return; - } - mChannelDataManager.addListener(mDuringScanChannelListener); - } - - private void onChannelUpdatedDuringScan(Set<Channel> currentChannels) { - String lineupId = pickBestLineupId(currentChannels); - Log.i(TAG, "Fast fetch channels for lineup ID: " + lineupId); - if (TextUtils.isEmpty(lineupId)) { - if (TextUtils.isEmpty(mPossibleLineupId)) { - return; - } - } else if (!TextUtils.equals(lineupId, mPossibleLineupId)) { - mFetchedChannelIdsDuringScan.clear(); - mPossibleLineupId = lineupId; - } - List<Long> currentChannelIds = new ArrayList<>(); - for (Channel channel : currentChannels) { - currentChannelIds.add(channel.getId()); - } - mFetchedChannelIdsDuringScan.retainAll(currentChannelIds); - Set<EpgReader.EpgChannel> newChannels = new HashSet<>(); - for (EpgReader.EpgChannel epgChannel : - mEpgReader.getChannels(currentChannels, mPossibleLineupId)) { - if (!mFetchedChannelIdsDuringScan.contains(epgChannel.getChannel().getId())) { - newChannels.add(epgChannel); - mFetchedChannelIdsDuringScan.add(epgChannel.getChannel().getId()); - } - } - batchFetchEpg(newChannels, FETCH_DURING_SCAN_DURATION_SEC); - } - - private void onFinishFetchDuringScan() { - mChannelDataManager.removeListener(mDuringScanChannelListener); - EpgFetchHelper.setLastLineupId(mContext, mPossibleLineupId); - clearUnusedLineups(null); - mFetchedChannelIdsDuringScan.clear(); - synchronized (mFetchDuringScanHandlerLock) { - if (!hasMessages(MSG_PREPARE_FETCH_DURING_SCAN)) { - removeCallbacksAndMessages(null); - getLooper().quit(); - mFetchDuringScanHandler = null; - } - } - // Clear timestamp to make routine service start right away. - EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, 0); - Log.i(TAG, "EPG Fetching during channel scanning finished."); - new Handler(Looper.getMainLooper()) - .post( - new Runnable() { - @Override - public void run() { - fetchImmediately(); - } - }); - } - } -} diff --git a/src/com/android/tv/data/epg/EpgInputWhiteList.java b/src/com/android/tv/data/epg/EpgInputWhiteList.java deleted file mode 100644 index de0478fc..00000000 --- a/src/com/android/tv/data/epg/EpgInputWhiteList.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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.data.epg; - -import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; -import android.text.TextUtils; -import android.util.Log; -import com.android.tv.common.BuildConfig; -import com.android.tv.common.config.api.RemoteConfig; -import com.android.tv.common.experiments.Experiments; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -/** Checks if a package or a input is white listed. */ -public final class EpgInputWhiteList { - private static final boolean DEBUG = false; - private static final String TAG = "EpgInputWhiteList"; - @VisibleForTesting public static final String KEY = "live_channels_3rd_party_epg_inputs"; - private static final String QA_DEV_INPUTS = - "com.example.partnersupportsampletvinput/.SampleTvInputService"; - - /** Returns the package portion of a inputId */ - @Nullable - public static String getPackageFromInput(@Nullable String inputId) { - return inputId == null ? null : inputId.substring(0, inputId.indexOf("/")); - } - - private final RemoteConfig remoteConfig; - - public EpgInputWhiteList(RemoteConfig remoteConfig) { - this.remoteConfig = remoteConfig; - } - - public boolean isInputWhiteListed(String inputId) { - return getWhiteListedInputs().contains(inputId); - } - - public boolean isPackageWhiteListed(String packageName) { - if (DEBUG) Log.d(TAG, "isPackageWhiteListed " + packageName); - Set<String> whiteList = getWhiteListedInputs(); - for (String good : whiteList) { - try { - String goodPackage = getPackageFromInput(good); - if (goodPackage.equals(packageName)) { - return true; - } - } catch (Exception e) { - if (DEBUG) Log.d(TAG, "Error parsing package name of " + good, e); - continue; - } - } - return false; - } - - private Set<String> getWhiteListedInputs() { - Set<String> result = toInputSet(remoteConfig.getString(KEY)); - if (BuildConfig.ENG || Experiments.ENABLE_QA_FEATURES.get()) { - HashSet<String> moreInputs = new HashSet<>(toInputSet(QA_DEV_INPUTS)); - if (result.isEmpty()) { - result = moreInputs; - } else { - result.addAll(moreInputs); - } - } - if (DEBUG) Log.d(TAG, "getWhiteListedInputs " + result); - return result; - } - - private Set<String> toInputSet(String value) { - if (TextUtils.isEmpty(value)) { - return Collections.EMPTY_SET; - } - return new HashSet(Arrays.asList(value.split(","))); - } -} diff --git a/src/com/android/tv/data/epg/EpgReader.java b/src/com/android/tv/data/epg/EpgReader.java index 9c881439..d10a852c 100644 --- a/src/com/android/tv/data/epg/EpgReader.java +++ b/src/com/android/tv/data/epg/EpgReader.java @@ -23,27 +23,12 @@ import com.android.tv.data.Channel; import com.android.tv.data.Lineup; import com.android.tv.data.Program; import com.android.tv.dvr.data.SeriesInfo; -import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.Set; /** An interface used to retrieve the EPG data. This class should be used in worker thread. */ @WorkerThread public interface EpgReader { - - /** Value class that holds a EpgChannelId and its corresponding Channel */ - // TODO(b/72052568): Get autovalue to work in aosp - abstract class EpgChannel { - public static EpgChannel createEpgChannel(Channel channel, String epgChannelId) { - return new AutoValue_EpgReader_EpgChannel(channel, epgChannelId); - } - - public abstract Channel getChannel(); - - public abstract String getEpgChannelId(); - } - /** Checks if the reader is available. */ boolean isAvailable(); @@ -70,7 +55,7 @@ public interface EpgReader { * Returns the list of channels for the given lineup. The returned channels should map into the * existing channels on the device. This method is usually called after selecting the lineup. */ - Set<EpgChannel> getChannels(Set<Channel> inputChannels, @NonNull String lineupId); + List<Channel> getChannels(@NonNull String lineupId); /** Pre-loads and caches channels for a given lineup. */ void preloadChannels(@NonNull String lineupId); @@ -80,19 +65,18 @@ public interface EpgReader { void clearCachedChannels(@NonNull String lineupId); /** - * Returns the programs for the given channel. Must call {@link #getChannels(Set, String)} + * Returns the programs for the given channel. Must call {@link #getChannels(String)} * beforehand. Note that the {@code Program} doesn't have valid program ID because it's not * retrieved from TvProvider. */ - List<Program> getPrograms(EpgChannel epgChannel); + List<Program> getPrograms(long channelId); /** * Returns the programs for the given channels. Note that the {@code Program} doesn't have valid * program ID because it's not retrieved from TvProvider. This method is only used to get * programs for a short duration typically. */ - Map<EpgChannel, Collection<Program>> getPrograms( - @NonNull Set<EpgChannel> epgChannels, long duration); + Map<Long, List<Program>> getPrograms(@NonNull List<Long> channelIds, long duration); /** Returns the series information for the given series ID. */ SeriesInfo getSeriesInfo(@NonNull String seriesId); diff --git a/src/com/android/tv/data/epg/StubEpgReader.java b/src/com/android/tv/data/epg/StubEpgReader.java index 9a87619d..49409a1d 100644 --- a/src/com/android/tv/data/epg/StubEpgReader.java +++ b/src/com/android/tv/data/epg/StubEpgReader.java @@ -22,11 +22,9 @@ import com.android.tv.data.Channel; import com.android.tv.data.Lineup; import com.android.tv.data.Program; import com.android.tv.dvr.data.SeriesInfo; -import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Set; /** A stub class to read EPG. */ public class StubEpgReader implements EpgReader { @@ -58,8 +56,8 @@ public class StubEpgReader implements EpgReader { } @Override - public Set<EpgChannel> getChannels(Set<Channel> inputChannels, @NonNull String lineupId) { - return Collections.emptySet(); + public List<Channel> getChannels(@NonNull String lineupId) { + return Collections.emptyList(); } @Override @@ -73,13 +71,12 @@ public class StubEpgReader implements EpgReader { } @Override - public List<Program> getPrograms(EpgChannel epgChannel) { + public List<Program> getPrograms(long channelId) { return Collections.emptyList(); } @Override - public Map<EpgChannel, Collection<Program>> getPrograms( - @NonNull Set<EpgChannel> channels, long duration) { + public Map<Long, List<Program>> getPrograms(@NonNull List<Long> channelIds, long duration) { return Collections.emptyMap(); } diff --git a/src/com/android/tv/dialog/DvrHistoryDialogFragment.java b/src/com/android/tv/dialog/DvrHistoryDialogFragment.java index 173a2891..442a663d 100644 --- a/src/com/android/tv/dialog/DvrHistoryDialogFragment.java +++ b/src/com/android/tv/dialog/DvrHistoryDialogFragment.java @@ -30,8 +30,9 @@ import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.ListView; import android.widget.TextView; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.dvr.DvrDataManager; @@ -44,7 +45,6 @@ import java.util.List; /** Displays the DVR history. */ @TargetApi(VERSION_CODES.N) -@SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated public class DvrHistoryDialogFragment extends SafeDismissDialogFragment { public static final String DIALOG_TAG = DvrHistoryDialogFragment.class.getSimpleName(); @@ -53,7 +53,7 @@ public class DvrHistoryDialogFragment extends SafeDismissDialogFragment { @Override public Dialog onCreateDialog(Bundle savedInstanceState) { - TvSingletons singletons = TvSingletons.getSingletons(getContext()); + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); DvrDataManager dataManager = singletons.getDvrDataManager(); ChannelDataManager channelDataManager = singletons.getChannelDataManager(); for (ScheduledRecording schedule : dataManager.getAllScheduledRecordings()) { diff --git a/src/com/android/tv/dialog/PinDialogFragment.java b/src/com/android/tv/dialog/PinDialogFragment.java index 71f45fbe..ccc3a983 100644 --- a/src/com/android/tv/dialog/PinDialogFragment.java +++ b/src/com/android/tv/dialog/PinDialogFragment.java @@ -45,13 +45,13 @@ import android.widget.FrameLayout; import android.widget.TextView; import android.widget.Toast; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.util.TvSettings; public class PinDialogFragment extends SafeDismissDialogFragment { private static final String TAG = "PinDialogFragment"; - private static final boolean DEBUG = false; + private static final boolean DEBUG = true; /** PIN code dialog for unlock channel */ public static final int PIN_DIALOG_TYPE_UNLOCK_CHANNEL = 0; @@ -68,7 +68,7 @@ public class PinDialogFragment extends SafeDismissDialogFragment { /** PIN code dialog for set new PIN */ public static final int PIN_DIALOG_TYPE_NEW_PIN = 3; - // PIN code dialog for checking old PIN. Only used in this class. + // PIN code dialog for checking old PIN. This is internal only. private static final int PIN_DIALOG_TYPE_OLD_PIN = 4; /** PIN code dialog for unlocking DVR playback */ @@ -192,7 +192,7 @@ public class PinDialogFragment extends SafeDismissDialogFragment { mTitleView.setText( getString( R.string.pin_enter_unlock_dvr, - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getTvInputManagerHelper() .getContentRatingsManager() .getDisplayNameForRating(tvContentRating))); diff --git a/src/com/android/tv/dialog/SafeDismissDialogFragment.java b/src/com/android/tv/dialog/SafeDismissDialogFragment.java index 6eb67dfd..18460cb6 100644 --- a/src/com/android/tv/dialog/SafeDismissDialogFragment.java +++ b/src/com/android/tv/dialog/SafeDismissDialogFragment.java @@ -19,7 +19,7 @@ package com.android.tv.dialog; import android.app.Activity; import android.app.DialogFragment; import com.android.tv.MainActivity; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.HasTrackerLabel; import com.android.tv.analytics.Tracker; @@ -37,7 +37,7 @@ public abstract class SafeDismissDialogFragment extends DialogFragment implement if (activity instanceof MainActivity) { mActivity = (MainActivity) activity; } - mTracker = TvSingletons.getSingletons(activity).getTracker(); + mTracker = TvApplication.getSingletons(activity).getTracker(); if (mDismissPending) { mDismissPending = false; dismiss(); diff --git a/src/com/android/tv/dvr/BaseDvrDataManager.java b/src/com/android/tv/dvr/BaseDvrDataManager.java index 0befba9c..342e4b21 100644 --- a/src/com/android/tv/dvr/BaseDvrDataManager.java +++ b/src/com/android/tv/dvr/BaseDvrDataManager.java @@ -25,11 +25,11 @@ import android.util.ArraySet; import android.util.Log; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.util.Clock; import com.android.tv.dvr.data.RecordedProgram; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.data.ScheduledRecording.RecordingState; import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.util.Clock; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; diff --git a/src/com/android/tv/dvr/DvrDataManagerImpl.java b/src/com/android/tv/dvr/DvrDataManagerImpl.java index 28006b08..17ea63a0 100644 --- a/src/com/android/tv/dvr/DvrDataManagerImpl.java +++ b/src/com/android/tv/dvr/DvrDataManagerImpl.java @@ -38,12 +38,9 @@ import android.text.TextUtils; import android.util.ArraySet; import android.util.Log; import android.util.Range; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.recording.RecordingStorageStatusManager; -import com.android.tv.common.recording.RecordingStorageStatusManager.OnStorageMountChangedListener; -import com.android.tv.common.util.Clock; -import com.android.tv.common.util.CommonUtils; +import com.android.tv.dvr.DvrStorageStatusManager.OnStorageMountChangedListener; import com.android.tv.dvr.data.IdGenerator; import com.android.tv.dvr.data.RecordedProgram; import com.android.tv.dvr.data.ScheduledRecording; @@ -61,9 +58,11 @@ import com.android.tv.dvr.provider.DvrDbSync; import com.android.tv.dvr.recorder.SeriesRecordingScheduler; import com.android.tv.util.AsyncDbTask; import com.android.tv.util.AsyncDbTask.AsyncRecordedProgramQueryTask; +import com.android.tv.util.Clock; import com.android.tv.util.Filter; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.TvUriMatcher; +import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -115,7 +114,7 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { private boolean mRecordedProgramLoadFinished; private final Set<AsyncTask> mPendingTasks = new ArraySet<>(); private DvrDbSync mDbSync; - private RecordingStorageStatusManager mStorageStatusManager; + private DvrStorageStatusManager mStorageStatusManager; private final TvInputCallback mInputCallback = new TvInputCallback() { @@ -141,7 +140,7 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { @Override public void onStorageMountChanged(boolean storageMounted) { for (TvInputInfo input : mInputManager.getTvInputInfos(true, true)) { - if (CommonUtils.isBundledInput(input.getId())) { + if (Utils.isBundledInput(input.getId())) { if (storageMounted) { unhideInput(input.getId()); } else { @@ -170,9 +169,8 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { public DvrDataManagerImpl(Context context, Clock clock) { super(context, clock); mContext = context; - mInputManager = TvSingletons.getSingletons(context).getTvInputManagerHelper(); - mStorageStatusManager = - TvSingletons.getSingletons(context).getRecordingStorageStatusManager(); + mInputManager = TvApplication.getSingletons(context).getTvInputManagerHelper(); + mStorageStatusManager = TvApplication.getSingletons(context).getDvrStorageStatusManager(); } public void start() { @@ -610,8 +608,9 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { SoftPreconditions.checkArgument( previousSeries == null, TAG, - "Attempt to add series" + " recording with the duplicate series ID: %s", - r.getSeriesId()); + "Attempt to add series" + + " recording with the duplicate series ID: " + + r.getSeriesId()); } if (mDvrLoadFinished) { notifySeriesRecordingAdded(seriesRecordings); @@ -780,14 +779,13 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { if (!SoftPreconditions.checkArgument( mSeriesRecordings.containsKey(r.getId()), TAG, - "Non Existing Series ID: %s", - r)) { + "Non Existing Series ID: " + r)) { continue; } SeriesRecording old1 = mSeriesRecordings.put(r.getId(), r); SeriesRecording old2 = mSeriesId2SeriesRecordings.put(r.getSeriesId(), r); SoftPreconditions.checkArgument( - old1.equals(old2), TAG, "Series ID cannot be updated: %s", r); + old1.equals(old2), TAG, "Series ID cannot be" + " updated: " + r); } if (mDvrLoadFinished) { notifySeriesRecordingChanged(seriesRecordings); @@ -797,8 +795,7 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { private boolean isInputAvailable(String inputId) { return mInputManager.hasTvInputInfo(inputId) - && (!CommonUtils.isBundledInput(inputId) - || mStorageStatusManager.isStorageMounted()); + && (!Utils.isBundledInput(inputId) || mStorageStatusManager.isStorageMounted()); } private void removeDeletedSchedules(ScheduledRecording... addedSchedules) { diff --git a/src/com/android/tv/dvr/DvrManager.java b/src/com/android/tv/dvr/DvrManager.java index 247e1bc5..50751d95 100644 --- a/src/com/android/tv/dvr/DvrManager.java +++ b/src/com/android/tv/dvr/DvrManager.java @@ -36,10 +36,10 @@ import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; import android.util.Log; import android.util.Range; -import com.android.tv.TvSingletons; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.util.CommonUtils; import com.android.tv.data.Channel; import com.android.tv.data.Program; import com.android.tv.dvr.DvrDataManager.OnRecordedProgramLoadFinishedListener; @@ -78,9 +78,9 @@ public class DvrManager { public DvrManager(Context context) { SoftPreconditions.checkFeatureEnabled(context, CommonFeatures.DVR, TAG); mAppContext = context.getApplicationContext(); - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - mDataManager = (WritableDvrDataManager) tvSingletons.getDvrDataManager(); - mScheduleManager = tvSingletons.getDvrScheduleManager(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager(); + mScheduleManager = appSingletons.getDvrScheduleManager(); if (mDataManager.isInitialized() && mScheduleManager.isInitialized()) { createSeriesRecordingsForRecordedProgramsIfNeeded(mDataManager.getRecordedPrograms()); } else { @@ -666,7 +666,7 @@ public class DvrManager { return false; } Program program = - TvSingletons.getSingletons(mAppContext) + TvApplication.getSingletons(mAppContext) .getProgramDataManager() .getCurrentProgram(channel.getId()); return program == null || !program.isRecordingProhibited(); @@ -683,7 +683,7 @@ public class DvrManager { return false; } Channel channel = - TvSingletons.getSingletons(mAppContext) + TvApplication.getSingletons(mAppContext) .getChannelDataManager() .getChannel(program.getChannelId()); if (channel == null || channel.isRecordingProhibited()) { @@ -833,7 +833,7 @@ public class DvrManager { if (!recordedProgramPath.exists()) { if (DEBUG) Log.d(TAG, "File to delete not exist: " + recordedProgramPath); } else { - CommonUtils.deleteDirOrFile(recordedProgramPath); + Utils.deleteDirOrFile(recordedProgramPath); if (DEBUG) { Log.d(TAG, "Sucessfully deleted files of the recorded program: " + dataUri); } diff --git a/src/com/android/tv/dvr/DvrScheduleManager.java b/src/com/android/tv/dvr/DvrScheduleManager.java index cbb89290..62f93c8b 100644 --- a/src/com/android/tv/dvr/DvrScheduleManager.java +++ b/src/com/android/tv/dvr/DvrScheduleManager.java @@ -25,7 +25,8 @@ import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; import android.util.ArraySet; import android.util.Range; -import com.android.tv.TvSingletons; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; @@ -50,7 +51,6 @@ import java.util.concurrent.CopyOnWriteArraySet; /** A class to manage the schedules. */ @TargetApi(Build.VERSION_CODES.N) @MainThread -@SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated public class DvrScheduleManager { private static final String TAG = "DvrScheduleManager"; @@ -94,9 +94,9 @@ public class DvrScheduleManager { public DvrScheduleManager(Context context) { mContext = context; - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - mDataManager = (DvrDataManagerImpl) tvSingletons.getDvrDataManager(); - mChannelDataManager = tvSingletons.getChannelDataManager(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mDataManager = (DvrDataManagerImpl) appSingletons.getDvrDataManager(); + mChannelDataManager = appSingletons.getChannelDataManager(); if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) { buildData(); } else { @@ -126,7 +126,7 @@ public class DvrScheduleManager { TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId()); if (!SoftPreconditions.checkArgument( - input != null, TAG, "Input was removed for : %s", schedule)) { + input != null, TAG, "Input was removed for : " + schedule)) { // Input removed. mInputScheduleMap.remove(schedule.getInputId()); mInputConflictInfoMap.remove(schedule.getInputId()); @@ -190,7 +190,7 @@ public class DvrScheduleManager { TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId()); if (!SoftPreconditions.checkArgument( - input != null, TAG, "Input was removed for : %s", schedule)) { + input != null, TAG, "Input was removed for : " + schedule)) { // Input removed. mInputScheduleMap.remove(schedule.getInputId()); mInputConflictInfoMap.remove(schedule.getInputId()); diff --git a/src/com/android/tv/dvr/DvrStorageStatusManager.java b/src/com/android/tv/dvr/DvrStorageStatusManager.java index fe5a47b8..a2f4bda8 100644 --- a/src/com/android/tv/dvr/DvrStorageStatusManager.java +++ b/src/com/android/tv/dvr/DvrStorageStatusManager.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 The Android Open Source Project + * 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. @@ -11,56 +11,272 @@ * 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. + * limitations under the License */ + package com.android.tv.dvr; +import android.content.BroadcastReceiver; import android.content.ContentProviderOperation; import android.content.ContentResolver; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.content.OperationApplicationException; import android.database.Cursor; +import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.net.Uri; import android.os.AsyncTask; +import android.os.Environment; +import android.os.Looper; import android.os.RemoteException; -import android.support.media.tv.TvContractCompat; +import android.os.StatFs; +import android.support.annotation.AnyThread; +import android.support.annotation.IntDef; +import android.support.annotation.WorkerThread; import android.util.Log; -import com.android.tv.TvSingletons; -import com.android.tv.common.recording.RecordingStorageStatusManager; -import com.android.tv.common.util.CommonUtils; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.feature.CommonFeatures; import com.android.tv.util.TvInputManagerHelper; +import com.android.tv.util.Utils; import java.io.File; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; -/** A class for extending TV app-specific function to {@link RecordingStorageStatusManager}. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed -public class DvrStorageStatusManager extends RecordingStorageStatusManager { +/** Signals DVR storage status change such as plugging/unplugging. */ +public class DvrStorageStatusManager { private static final String TAG = "DvrStorageStatusManager"; + private static final boolean DEBUG = false; - private final Context mContext; - private CleanUpDbTask mCleanUpDbTask; + /** Minimum storage size to support DVR */ + public static final long MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES = 50 * 1024 * 1024 * 1024L; // 50GB + + private static final long MIN_FREE_STORAGE_SIZE_FOR_DVR_IN_BYTES = + 10 * 1024 * 1024 * 1024L; // 10GB + private static final String RECORDING_DATA_SUB_PATH = "/recording"; private static final String[] PROJECTION = { - TvContractCompat.RecordedPrograms._ID, - TvContractCompat.RecordedPrograms.COLUMN_PACKAGE_NAME, - TvContractCompat.RecordedPrograms.COLUMN_RECORDING_DATA_URI + TvContract.RecordedPrograms._ID, + TvContract.RecordedPrograms.COLUMN_PACKAGE_NAME, + TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI }; private static final int BATCH_OPERATION_COUNT = 100; - public DvrStorageStatusManager(Context context) { - super(context); + @IntDef({ + STORAGE_STATUS_OK, + STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL, + STORAGE_STATUS_FREE_SPACE_INSUFFICIENT, + STORAGE_STATUS_MISSING + }) + @Retention(RetentionPolicy.SOURCE) + public @interface StorageStatus {} + + /** Current storage is OK to record a program. */ + public static final int STORAGE_STATUS_OK = 0; + + /** Current storage's total capacity is smaller than DVR requirement. */ + public static final int STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL = 1; + + /** Current storage's free space is insufficient to record programs. */ + public static final int STORAGE_STATUS_FREE_SPACE_INSUFFICIENT = 2; + + /** Current storage is missing. */ + public static final int STORAGE_STATUS_MISSING = 3; + + private final Context mContext; + private final Set<OnStorageMountChangedListener> mOnStorageMountChangedListeners = + new CopyOnWriteArraySet<>(); + private final boolean mRunningInMainProcess; + private MountedStorageStatus mMountedStorageStatus; + private boolean mStorageValid; + private CleanUpDbTask mCleanUpDbTask; + + private class MountedStorageStatus { + private final boolean mStorageMounted; + private final File mStorageMountedDir; + private final long mStorageMountedCapacity; + + private MountedStorageStatus(boolean mounted, File mountedDir, long capacity) { + mStorageMounted = mounted; + mStorageMountedDir = mountedDir; + mStorageMountedCapacity = capacity; + } + + private boolean isValidForDvr() { + return mStorageMounted && mStorageMountedCapacity >= MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof MountedStorageStatus)) { + return false; + } + MountedStorageStatus status = (MountedStorageStatus) other; + return mStorageMounted == status.mStorageMounted + && Objects.equals(mStorageMountedDir, status.mStorageMountedDir) + && mStorageMountedCapacity == status.mStorageMountedCapacity; + } + } + + public interface OnStorageMountChangedListener { + + /** + * Listener for DVR storage status change. + * + * @param storageMounted {@code true} when DVR possible storage is mounted, {@code false} + * otherwise. + */ + void onStorageMountChanged(boolean storageMounted); + } + + private final class StorageStatusBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + MountedStorageStatus result = getStorageStatusInternal(); + if (mMountedStorageStatus.equals(result)) { + return; + } + mMountedStorageStatus = result; + if (result.mStorageMounted && mRunningInMainProcess) { + // Cleans up DB in LC process. + // Tuner process is not always on. + if (mCleanUpDbTask != null) { + mCleanUpDbTask.cancel(true); + } + mCleanUpDbTask = new CleanUpDbTask(); + mCleanUpDbTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + boolean valid = result.isValidForDvr(); + if (valid == mStorageValid) { + return; + } + mStorageValid = valid; + for (OnStorageMountChangedListener l : mOnStorageMountChangedListeners) { + l.onStorageMountChanged(valid); + } + } + } + + /** + * Creates DvrStorageStatusManager. + * + * @param context {@link Context} + */ + public DvrStorageStatusManager(final Context context, boolean runningInMainProcess) { mContext = context; + mRunningInMainProcess = runningInMainProcess; + mMountedStorageStatus = getStorageStatusInternal(); + mStorageValid = mMountedStorageStatus.isValidForDvr(); + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_MEDIA_MOUNTED); + filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED); + filter.addAction(Intent.ACTION_MEDIA_EJECT); + filter.addAction(Intent.ACTION_MEDIA_REMOVED); + filter.addAction(Intent.ACTION_MEDIA_BAD_REMOVAL); + filter.addDataScheme(ContentResolver.SCHEME_FILE); + mContext.registerReceiver(new StorageStatusBroadcastReceiver(), filter); + } + + /** + * Adds the listener for receiving storage status change. + * + * @param listener + */ + public void addListener(OnStorageMountChangedListener listener) { + mOnStorageMountChangedListeners.add(listener); + } + + /** Removes the current listener. */ + public void removeListener(OnStorageMountChangedListener listener) { + mOnStorageMountChangedListeners.remove(listener); + } + + /** Returns true if a storage is mounted. */ + public boolean isStorageMounted() { + return mMountedStorageStatus.mStorageMounted; + } + + /** Returns the path to DVR recording data directory. This can take for a while sometimes. */ + @WorkerThread + public File getRecordingRootDataDirectory() { + SoftPreconditions.checkState(Looper.myLooper() != Looper.getMainLooper()); + if (mMountedStorageStatus.mStorageMountedDir == null) { + return null; + } + File root = mContext.getExternalFilesDir(null); + String rootPath; + try { + rootPath = root != null ? root.getCanonicalPath() : null; + } catch (IOException | SecurityException e) { + return null; + } + return rootPath == null ? null : new File(rootPath + RECORDING_DATA_SUB_PATH); + } + + /** + * Returns the current storage status for DVR recordings. + * + * @return {@link StorageStatus} + */ + @AnyThread + public @StorageStatus int getDvrStorageStatus() { + MountedStorageStatus status = mMountedStorageStatus; + if (status.mStorageMountedDir == null) { + return STORAGE_STATUS_MISSING; + } + if (CommonFeatures.FORCE_RECORDING_UNTIL_NO_SPACE.isEnabled(mContext)) { + return STORAGE_STATUS_OK; + } + if (status.mStorageMountedCapacity < MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES) { + return STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL; + } + try { + StatFs statFs = new StatFs(status.mStorageMountedDir.toString()); + if (statFs.getAvailableBytes() < MIN_FREE_STORAGE_SIZE_FOR_DVR_IN_BYTES) { + return STORAGE_STATUS_FREE_SPACE_INSUFFICIENT; + } + } catch (IllegalArgumentException e) { + // In rare cases, storage status change was not notified yet. + SoftPreconditions.checkState(false); + return STORAGE_STATUS_FREE_SPACE_INSUFFICIENT; + } + return STORAGE_STATUS_OK; } - @Override - protected void cleanUpDbIfNeeded() { - if (mCleanUpDbTask != null) { - mCleanUpDbTask.cancel(true); + /** + * Returns whether the storage has sufficient storage. + * + * @return {@code true} when there is sufficient storage, {@code false} otherwise + */ + public boolean isStorageSufficient() { + return getDvrStorageStatus() == STORAGE_STATUS_OK; + } + + private MountedStorageStatus getStorageStatusInternal() { + boolean storageMounted = + Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED); + File storageMountedDir = storageMounted ? Environment.getExternalStorageDirectory() : null; + storageMounted = storageMounted && storageMountedDir != null; + long storageMountedCapacity = 0L; + if (storageMounted) { + try { + StatFs statFs = new StatFs(storageMountedDir.toString()); + storageMountedCapacity = statFs.getTotalBytes(); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Storage mount status was changed."); + storageMounted = false; + storageMountedDir = null; + } } - mCleanUpDbTask = new CleanUpDbTask(); - mCleanUpDbTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + return new MountedStorageStatus(storageMounted, storageMountedDir, storageMountedCapacity); } private class CleanUpDbTask extends AsyncTask<Void, Void, Boolean> { @@ -72,11 +288,11 @@ public class DvrStorageStatusManager extends RecordingStorageStatusManager { @Override protected Boolean doInBackground(Void... params) { - @StorageStatus int storageStatus = getDvrStorageStatus(); - if (storageStatus == STORAGE_STATUS_MISSING) { + @DvrStorageStatusManager.StorageStatus int storageStatus = getDvrStorageStatus(); + if (storageStatus == DvrStorageStatusManager.STORAGE_STATUS_MISSING) { return null; } - if (storageStatus == STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL) { + if (storageStatus == DvrStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL) { return true; } List<ContentProviderOperation> ops = getDeleteOps(); @@ -94,7 +310,7 @@ public class DvrStorageStatusManager extends RecordingStorageStatusManager { ArrayList<ContentProviderOperation> batchOps = new ArrayList<>(ops.subList(i, toIndex)); try { - mContext.getContentResolver().applyBatch(TvContractCompat.AUTHORITY, batchOps); + mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, batchOps); } catch (RemoteException | OperationApplicationException e) { Log.e(TAG, "Failed to clean up RecordedPrograms.", e); } @@ -105,16 +321,16 @@ public class DvrStorageStatusManager extends RecordingStorageStatusManager { @Override protected void onPostExecute(Boolean forgetStorage) { if (forgetStorage != null && forgetStorage == true) { - DvrManager dvrManager = TvSingletons.getSingletons(mContext).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(mContext).getDvrManager(); TvInputManagerHelper tvInputManagerHelper = - TvSingletons.getSingletons(mContext).getTvInputManagerHelper(); + TvApplication.getSingletons(mContext).getTvInputManagerHelper(); List<TvInputInfo> tvInputInfoList = tvInputManagerHelper.getTvInputInfos(true, false); if (tvInputInfoList == null || tvInputInfoList.isEmpty()) { return; } for (TvInputInfo info : tvInputInfoList) { - if (CommonUtils.isBundledInput(info.getId())) { + if (Utils.isBundledInput(info.getId())) { dvrManager.forgetStorage(info.getId()); } } @@ -129,7 +345,7 @@ public class DvrStorageStatusManager extends RecordingStorageStatusManager { try (Cursor c = mContentResolver.query( - TvContractCompat.RecordedPrograms.CONTENT_URI, + TvContract.RecordedPrograms.CONTENT_URI, PROJECTION, null, null, @@ -138,8 +354,10 @@ public class DvrStorageStatusManager extends RecordingStorageStatusManager { return null; } while (c.moveToNext()) { - @StorageStatus int storageStatus = getDvrStorageStatus(); - if (isCancelled() || storageStatus == STORAGE_STATUS_MISSING) { + @DvrStorageStatusManager.StorageStatus + int storageStatus = getDvrStorageStatus(); + if (isCancelled() + || storageStatus == DvrStorageStatusManager.STORAGE_STATUS_MISSING) { ops.clear(); break; } @@ -150,7 +368,7 @@ public class DvrStorageStatusManager extends RecordingStorageStatusManager { continue; } Uri dataUri = Uri.parse(dataUriString); - if (!CommonUtils.isInBundledPackageSet(packageName) + if (!Utils.isInBundledPackageSet(packageName) || dataUri == null || dataUri.getPath() == null || !ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())) { @@ -160,7 +378,7 @@ public class DvrStorageStatusManager extends RecordingStorageStatusManager { if (!recordedProgramDir.exists()) { ops.add( ContentProviderOperation.newDelete( - TvContractCompat.buildRecordedProgramUri( + TvContract.buildRecordedProgramUri( Long.parseLong(id))) .build()); } diff --git a/src/com/android/tv/dvr/DvrWatchedPositionManager.java b/src/com/android/tv/dvr/DvrWatchedPositionManager.java index 8616962f..7da2bfc9 100644 --- a/src/com/android/tv/dvr/DvrWatchedPositionManager.java +++ b/src/com/android/tv/dvr/DvrWatchedPositionManager.java @@ -20,7 +20,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.media.tv.TvInputManager; import android.support.annotation.IntDef; -import com.android.tv.common.util.SharedPreferencesUtils; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.dvr.data.RecordedProgram; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/src/com/android/tv/dvr/data/RecordedProgram.java b/src/com/android/tv/dvr/data/RecordedProgram.java index e1fbca8c..18841ae5 100644 --- a/src/com/android/tv/dvr/data/RecordedProgram.java +++ b/src/com/android/tv/dvr/data/RecordedProgram.java @@ -30,10 +30,10 @@ import android.support.annotation.Nullable; import android.text.TextUtils; import com.android.tv.common.R; import com.android.tv.common.TvContentRatingCache; -import com.android.tv.common.util.CommonUtils; import com.android.tv.data.BaseProgram; import com.android.tv.data.GenreItems; import com.android.tv.data.InternalDataUtils; +import com.android.tv.util.Utils; import java.util.Arrays; import java.util.Collection; import java.util.Comparator; @@ -118,7 +118,7 @@ public class RecordedProgram extends BaseProgram { .setInternalProviderFlag3(cursor.getInt(index++)) .setInternalProviderFlag4(cursor.getInt(index++)) .setVersionNumber(cursor.getInt(index++)); - if (CommonUtils.isInBundledPackageSet(builder.mPackageName)) { + if (Utils.isInBundledPackageSet(builder.mPackageName)) { InternalDataUtils.deserializeInternalProviderData(cursor.getBlob(index), builder); } return builder.build(); diff --git a/src/com/android/tv/dvr/data/ScheduledRecording.java b/src/com/android/tv/dvr/data/ScheduledRecording.java index aa1dfc72..7de37ebc 100644 --- a/src/com/android/tv/dvr/data/ScheduledRecording.java +++ b/src/com/android/tv/dvr/data/ScheduledRecording.java @@ -16,25 +16,23 @@ package com.android.tv.dvr.data; -import android.annotation.TargetApi; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; -import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.IntDef; import android.text.TextUtils; import android.util.Range; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.CommonUtils; import com.android.tv.data.Channel; import com.android.tv.data.Program; import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.provider.DvrContract.Schedules; import com.android.tv.util.CompositeComparator; +import com.android.tv.util.Utils; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.Collection; @@ -42,8 +40,6 @@ import java.util.Comparator; import java.util.Objects; /** A data class for one recording contents. */ -@TargetApi(Build.VERSION_CODES.N) -@SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated public final class ScheduledRecording implements Parcelable { private static final String TAG = "ScheduledRecording"; @@ -659,7 +655,7 @@ public final class ScheduledRecording implements Parcelable { return mProgramTitle; } Channel channel = - TvSingletons.getSingletons(context).getChannelDataManager().getChannel(mChannelId); + TvApplication.getSingletons(context).getChannelDataManager().getChannel(mChannelId); return channel != null ? channel.getDisplayName() : context.getString(R.string.no_program_information); @@ -673,7 +669,7 @@ public final class ScheduledRecording implements Parcelable { case Schedules.TYPE_PROGRAM: return TYPE_PROGRAM; default: - SoftPreconditions.checkArgument(false, TAG, "Unknown recording type %s", type); + SoftPreconditions.checkArgument(false, TAG, "Unknown recording type " + type); return TYPE_TIMED; } } @@ -686,7 +682,7 @@ public final class ScheduledRecording implements Parcelable { case TYPE_PROGRAM: return Schedules.TYPE_PROGRAM; default: - SoftPreconditions.checkArgument(false, TAG, "Unknown recording type %s", type); + SoftPreconditions.checkArgument(false, TAG, "Unknown recording type " + type); return Schedules.TYPE_TIMED; } } @@ -712,7 +708,7 @@ public final class ScheduledRecording implements Parcelable { case Schedules.STATE_RECORDING_CANCELED: return STATE_RECORDING_CANCELED; default: - SoftPreconditions.checkArgument(false, TAG, "Unknown recording state %s", state); + SoftPreconditions.checkArgument(false, TAG, "Unknown recording state" + state); return STATE_RECORDING_NOT_STARTED; } } @@ -738,7 +734,7 @@ public final class ScheduledRecording implements Parcelable { case STATE_RECORDING_CANCELED: return Schedules.STATE_RECORDING_CANCELED; default: - SoftPreconditions.checkArgument(false, TAG, "Unknown recording state %s", state); + SoftPreconditions.checkArgument(false, TAG, "Unknown recording state" + state); return Schedules.STATE_RECORDING_NOT_STARTED; } } @@ -769,12 +765,12 @@ public final class ScheduledRecording implements Parcelable { + ",type=" + mType + ",startTime=" - + CommonUtils.toIsoDateTimeString(mStartTimeMs) + + Utils.toIsoDateTimeString(mStartTimeMs) + "(" + mStartTimeMs + ")" + ",endTime=" - + CommonUtils.toIsoDateTimeString(mEndTimeMs) + + Utils.toIsoDateTimeString(mEndTimeMs) + "(" + mEndTimeMs + ")" diff --git a/src/com/android/tv/dvr/data/SeriesRecording.java b/src/com/android/tv/dvr/data/SeriesRecording.java index 96b3425a..1fd1cea3 100644 --- a/src/com/android/tv/dvr/data/SeriesRecording.java +++ b/src/com/android/tv/dvr/data/SeriesRecording.java @@ -568,7 +568,7 @@ public class SeriesRecording implements Parcelable { mLongDescription, mSeriesId, mChannelOption, - Arrays.hashCode(mCanonicalGenreIds), + mCanonicalGenreIds, mPosterUri, mPhotoUri, mState); diff --git a/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java b/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java index db18e609..ad00bec8 100644 --- a/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java +++ b/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java @@ -20,18 +20,17 @@ import android.content.Context; import android.database.Cursor; import android.os.AsyncTask; import android.support.annotation.Nullable; -import com.android.tv.common.concurrent.NamedThreadFactory; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.provider.DvrContract.Schedules; import com.android.tv.dvr.provider.DvrContract.SeriesRecordings; +import com.android.tv.util.NamedThreadFactory; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** {@link AsyncTask} that defaults to executing on its own single threaded Executor Service. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public abstract class AsyncDvrDbTask<Params, Progress, Result> extends AsyncTask<Params, Progress, Result> { private static final NamedThreadFactory THREAD_FACTORY = diff --git a/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java b/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java index 0fb96d1b..fb793a0e 100644 --- a/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java +++ b/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java @@ -34,7 +34,7 @@ import com.android.tv.dvr.provider.DvrContract.SeriesRecordings; /** A data class for one recorded contents. */ public class DvrDatabaseHelper extends SQLiteOpenHelper { private static final String TAG = "DvrDatabaseHelper"; - private static final boolean DEBUG = false; + private static final boolean DEBUG = true; private static final int DATABASE_VERSION = 17; private static final String DB_NAME = "dvr.db"; diff --git a/src/com/android/tv/dvr/provider/DvrDbSync.java b/src/com/android/tv/dvr/provider/DvrDbSync.java index 8bd16221..1cdeef24 100644 --- a/src/com/android/tv/dvr/provider/DvrDbSync.java +++ b/src/com/android/tv/dvr/provider/DvrDbSync.java @@ -29,7 +29,7 @@ import android.os.Looper; import android.support.annotation.MainThread; import android.support.annotation.VisibleForTesting; import android.util.Log; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.Program; import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; @@ -140,8 +140,8 @@ public class DvrDbSync { this( context, dataManager, - TvSingletons.getSingletons(context).getChannelDataManager(), - TvSingletons.getSingletons(context).getDvrManager(), + TvApplication.getSingletons(context).getChannelDataManager(), + TvApplication.getSingletons(context).getDvrManager(), SeriesRecordingScheduler.getInstance(context)); } diff --git a/src/com/android/tv/dvr/provider/EpisodicProgramLoadTask.java b/src/com/android/tv/dvr/provider/EpisodicProgramLoadTask.java index 7cdc7b73..e9ca11e5 100644 --- a/src/com/android/tv/dvr/provider/EpisodicProgramLoadTask.java +++ b/src/com/android/tv/dvr/provider/EpisodicProgramLoadTask.java @@ -25,9 +25,8 @@ import android.net.Uri; import android.os.Build; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.PermissionUtils; import com.android.tv.data.Program; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.data.ScheduledRecording; @@ -35,6 +34,7 @@ import com.android.tv.dvr.data.SeasonEpisodeNumber; import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.util.AsyncDbTask.AsyncProgramQueryTask; import com.android.tv.util.AsyncDbTask.CursorFilter; +import com.android.tv.util.PermissionUtils; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -91,7 +91,7 @@ public abstract class EpisodicProgramLoadTask { */ public EpisodicProgramLoadTask(Context context, Collection<SeriesRecording> seriesRecordings) { mContext = context.getApplicationContext(); - mDataManager = TvSingletons.getSingletons(context).getDvrDataManager(); + mDataManager = TvApplication.getSingletons(context).getDvrDataManager(); mSeriesRecordings.addAll(seriesRecordings); } diff --git a/src/com/android/tv/dvr/recorder/ConflictChecker.java b/src/com/android/tv/dvr/recorder/ConflictChecker.java index f5bc7b9f..732815cd 100644 --- a/src/com/android/tv/dvr/recorder/ConflictChecker.java +++ b/src/com/android/tv/dvr/recorder/ConflictChecker.java @@ -27,10 +27,11 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.ArraySet; import android.util.Log; +import com.android.tv.ApplicationSingletons; import com.android.tv.InputSessionManager; import com.android.tv.InputSessionManager.OnTvViewChannelChangeListener; import com.android.tv.MainActivity; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.WeakHandler; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; @@ -39,7 +40,6 @@ import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.ui.DvrUiHelper; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -88,35 +88,21 @@ public class ConflictChecker { new ScheduledRecordingListener() { @Override public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { - if (DEBUG) { - Log.d( - TAG, - "onScheduledRecordingAdded: " - + Arrays.toString(scheduledRecordings)); - } + if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + scheduledRecordings); mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT); } @Override public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { - if (DEBUG) { - Log.d( - TAG, - "onScheduledRecordingRemoved: " - + Arrays.toString(scheduledRecordings)); - } + if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + scheduledRecordings); mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT); } @Override public void onScheduledRecordingStatusChanged( ScheduledRecording... scheduledRecordings) { - if (DEBUG) { - Log.d( - TAG, - "onScheduledRecordingStatusChanged: " - + Arrays.toString(scheduledRecordings)); - } + if (DEBUG) + Log.d(TAG, "onScheduledRecordingStatusChanged: " + scheduledRecordings); mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT); } }; @@ -133,10 +119,10 @@ public class ConflictChecker { public ConflictChecker(MainActivity mainActivity) { mMainActivity = mainActivity; - TvSingletons tvSingletons = TvSingletons.getSingletons(mainActivity); - mChannelDataManager = tvSingletons.getChannelDataManager(); - mScheduleManager = tvSingletons.getDvrScheduleManager(); - mSessionManager = tvSingletons.getInputSessionManager(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(mainActivity); + mChannelDataManager = appSingletons.getChannelDataManager(); + mScheduleManager = appSingletons.getDvrScheduleManager(); + mSessionManager = appSingletons.getInputSessionManager(); } /** Starts checking the conflict. */ diff --git a/src/com/android/tv/dvr/recorder/DvrRecordingService.java b/src/com/android/tv/dvr/recorder/DvrRecordingService.java index 9fdbf062..3b21bab2 100644 --- a/src/com/android/tv/dvr/recorder/DvrRecordingService.java +++ b/src/com/android/tv/dvr/recorder/DvrRecordingService.java @@ -29,15 +29,15 @@ import android.support.annotation.Nullable; import android.support.annotation.RequiresApi; import android.support.annotation.VisibleForTesting; import android.util.Log; +import com.android.tv.ApplicationSingletons; import com.android.tv.InputSessionManager; import com.android.tv.InputSessionManager.OnRecordingSessionChangeListener; import com.android.tv.R; -import com.android.tv.Starter; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.util.Clock; import com.android.tv.dvr.WritableDvrDataManager; +import com.android.tv.util.Clock; import com.android.tv.util.RecurringRunner; /** @@ -114,12 +114,12 @@ public class DvrRecordingService extends Service { @Override public void onCreate() { - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); if (DEBUG) Log.d(TAG, "onCreate"); super.onCreate(); SoftPreconditions.checkFeatureEnabled(this, CommonFeatures.DVR, TAG); sInstance = this; - TvSingletons singletons = TvSingletons.getSingletons(this); + ApplicationSingletons singletons = TvApplication.getSingletons(this); WritableDvrDataManager dataManager = (WritableDvrDataManager) singletons.getDvrDataManager(); mSessionManager = singletons.getInputSessionManager(); @@ -183,6 +183,7 @@ public class DvrRecordingService extends Service { @VisibleForTesting protected void startForegroundInternal(boolean hasUpcomingRecording) { + // STOPSHIP: Replace the content title with real UX strings Notification.Builder builder = new Notification.Builder(this) .setContentTitle(mContentTitle) @@ -203,6 +204,7 @@ public class DvrRecordingService extends Service { private void createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // STOPSHIP: Replace the channel name with real UX strings mNotificationChannel = new NotificationChannel( DVR_NOTIFICATION_CHANNEL_ID, diff --git a/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java b/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java index bb5ea99d..f7521d6a 100644 --- a/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java +++ b/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java @@ -21,16 +21,15 @@ import android.content.Context; import android.content.Intent; import android.os.Build; import android.support.annotation.RequiresApi; -import com.android.tv.Starter; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; /** Signals the DVR to start recording shows <i>soon</i>. */ @RequiresApi(Build.VERSION_CODES.N) public class DvrStartRecordingReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - Starter.start(context); - RecordingScheduler scheduler = TvSingletons.getSingletons(context).getRecordingScheduler(); + TvApplication.setCurrentRunningProcess(context, true); + RecordingScheduler scheduler = TvApplication.getSingletons(context).getRecordingScheduler(); if (scheduler != null) { scheduler.updateAndStartServiceIfNeeded(); } diff --git a/src/com/android/tv/dvr/recorder/InputTaskScheduler.java b/src/com/android/tv/dvr/recorder/InputTaskScheduler.java index 722e75fc..ff46c7c3 100644 --- a/src/com/android/tv/dvr/recorder/InputTaskScheduler.java +++ b/src/com/android/tv/dvr/recorder/InputTaskScheduler.java @@ -26,13 +26,13 @@ import android.util.ArrayMap; import android.util.Log; import android.util.LongSparseArray; import com.android.tv.InputSessionManager; -import com.android.tv.common.util.Clock; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.WritableDvrDataManager; import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.util.Clock; import com.android.tv.util.CompositeComparator; import java.util.ArrayList; import java.util.Collections; @@ -443,7 +443,6 @@ public class InputTaskScheduler { break; case MSG_UPDATE_SCHEDULED_RECORDING: handleUpdateSchedule((ScheduledRecording) msg.obj); - break; case MSG_BUILD_SCHEDULE: handleBuildSchedule(); break; diff --git a/src/com/android/tv/dvr/recorder/RecordingScheduler.java b/src/com/android/tv/dvr/recorder/RecordingScheduler.java index d631d84f..ea54f8c3 100644 --- a/src/com/android/tv/dvr/recorder/RecordingScheduler.java +++ b/src/com/android/tv/dvr/recorder/RecordingScheduler.java @@ -31,10 +31,10 @@ import android.support.annotation.VisibleForTesting; import android.util.ArrayMap; import android.util.Log; import android.util.Range; +import com.android.tv.ApplicationSingletons; import com.android.tv.InputSessionManager; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.Clock; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.ChannelDataManager.Listener; import com.android.tv.dvr.DvrDataManager; @@ -43,6 +43,7 @@ import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.WritableDvrDataManager; import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.util.Clock; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import java.util.Arrays; @@ -119,10 +120,10 @@ public class RecordingScheduler extends TvInputCallback implements ScheduledReco */ public static RecordingScheduler createScheduler(Context context) { SoftPreconditions.checkState( - TvSingletons.getSingletons(context).getRecordingScheduler() == null); + TvApplication.getSingletons(context).getRecordingScheduler() == null); HandlerThread handlerThread = new HandlerThread(HANDLER_THREAD_NAME); handlerThread.start(); - TvSingletons singletons = TvSingletons.getSingletons(context); + ApplicationSingletons singletons = TvApplication.getSingletons(context); return new RecordingScheduler( handlerThread.getLooper(), singletons.getDvrManager(), diff --git a/src/com/android/tv/dvr/recorder/RecordingTask.java b/src/com/android/tv/dvr/recorder/RecordingTask.java index 4bd73e8a..85c6a0d5 100644 --- a/src/com/android/tv/dvr/recorder/RecordingTask.java +++ b/src/com/android/tv/dvr/recorder/RecordingTask.java @@ -33,15 +33,14 @@ import android.widget.Toast; import com.android.tv.InputSessionManager; import com.android.tv.InputSessionManager.RecordingSession; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.Clock; -import com.android.tv.common.util.CommonUtils; import com.android.tv.data.Channel; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.WritableDvrDataManager; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.recorder.InputTaskScheduler.HandlerWrapper; +import com.android.tv.util.Clock; import com.android.tv.util.Utils; import java.util.Comparator; import java.util.concurrent.TimeUnit; @@ -179,7 +178,7 @@ public class RecordingTask extends RecordingCallback release(); return false; default: - SoftPreconditions.checkArgument(false, TAG, "unexpected message type %s", msg); + SoftPreconditions.checkArgument(false, TAG, "unexpected message type " + msg); break; } return true; @@ -254,7 +253,7 @@ public class RecordingTask extends RecordingCallback new Runnable() { @Override public void run() { - if (TvSingletons.getSingletons(mContext) + if (TvApplication.getSingletons(mContext) .getMainActivityWrapper() .isResumed()) { ScheduledRecording scheduledRecording = @@ -282,7 +281,7 @@ public class RecordingTask extends RecordingCallback } } }); - // fall through + // Pass through default: failAndQuit(); break; @@ -427,7 +426,7 @@ public class RecordingTask extends RecordingCallback + " with a delay of " + delay / 1000 + " seconds to arrive at " - + CommonUtils.toIsoDateTimeString(when)); + + Utils.toIsoDateTimeString(when)); } return mHandler.sendEmptyMessageDelayed(what, delay); } diff --git a/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java b/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java index f30308f3..c59d4a93 100644 --- a/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java +++ b/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java @@ -18,10 +18,10 @@ package com.android.tv.dvr.recorder; import android.support.annotation.MainThread; import android.support.annotation.VisibleForTesting; -import com.android.tv.common.util.Clock; import com.android.tv.dvr.WritableDvrDataManager; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.util.Clock; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; diff --git a/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java b/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java index 4f7a789b..05f876ad 100644 --- a/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java +++ b/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java @@ -27,13 +27,13 @@ import android.text.TextUtils; import android.util.ArraySet; import android.util.Log; import android.util.LongSparseArray; -import com.android.tv.TvSingletons; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; +import com.android.tv.common.CollectionUtils; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.experiments.Experiments; -import com.android.tv.common.util.CollectionUtils; -import com.android.tv.common.util.SharedPreferencesUtils; import com.android.tv.data.Program; -import com.android.tv.data.epg.EpgReader; +import com.android.tv.data.epg.EpgFetcher; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener; @@ -44,6 +44,8 @@ import com.android.tv.dvr.data.SeasonEpisodeNumber; import com.android.tv.dvr.data.SeriesInfo; import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.provider.EpisodicProgramLoadTask; +import com.android.tv.experiments.Experiments; +import com.android.tv.util.LocationUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -56,7 +58,6 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; -import javax.inject.Provider; /** * Creates the {@link com.android.tv.dvr.data.ScheduledRecording}s for the {@link @@ -207,9 +208,9 @@ public class SeriesRecordingScheduler { private SeriesRecordingScheduler(Context context) { mContext = context.getApplicationContext(); - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - mDvrManager = tvSingletons.getDvrManager(); - mDataManager = (WritableDvrDataManager) tvSingletons.getDvrDataManager(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mDvrManager = appSingletons.getDvrManager(); + mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager(); mSharedPreferences = context.getSharedPreferences( SharedPreferencesUtils.SHARED_PREF_SERIES_RECORDINGS, Context.MODE_PRIVATE); @@ -262,10 +263,7 @@ public class SeriesRecordingScheduler { private void executeFetchSeriesInfoTask(SeriesRecording seriesRecording) { if (Experiments.CLOUD_EPG.get()) { - FetchSeriesInfoTask task = - new FetchSeriesInfoTask( - seriesRecording, - TvSingletons.getSingletons(mContext).providesEpgReader()); + FetchSeriesInfoTask task = new FetchSeriesInfoTask(seriesRecording); task.execute(); mFetchSeriesInfoTasks.put(seriesRecording.getId(), task); } @@ -536,18 +534,16 @@ public class SeriesRecordingScheduler { } private class FetchSeriesInfoTask extends AsyncTask<Void, Void, SeriesInfo> { - private final SeriesRecording mSeriesRecording; - private final Provider<EpgReader> mEpgReaderProvider; + private SeriesRecording mSeriesRecording; - FetchSeriesInfoTask( - SeriesRecording seriesRecording, Provider<EpgReader> epgReaderProvider) { + FetchSeriesInfoTask(SeriesRecording seriesRecording) { mSeriesRecording = seriesRecording; - mEpgReaderProvider = epgReaderProvider; } @Override protected SeriesInfo doInBackground(Void... voids) { - return mEpgReaderProvider.get().getSeriesInfo(mSeriesRecording.getSeriesId()); + return EpgFetcher.createEpgReader(mContext, LocationUtils.getCurrentCountry(mContext)) + .getSeriesInfo(mSeriesRecording.getSeriesId()); } @Override diff --git a/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java b/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java index fce94230..f4077e44 100644 --- a/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java +++ b/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java @@ -25,7 +25,7 @@ import android.support.annotation.NonNull; import android.support.v17.leanback.widget.GuidanceStylist.Guidance; import android.support.v17.leanback.widget.GuidedAction; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Program; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.data.RecordedProgram; @@ -49,7 +49,7 @@ public class DvrAlreadyRecordedFragment extends DvrGuidedStepFragment { public void onAttach(Context context) { super.onAttach(context); mProgram = getArguments().getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM); - DvrManager dvrManager = TvSingletons.getSingletons(context).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(context).getDvrManager(); mDuplicate = dvrManager.getRecordedProgram( mProgram.getTitle(), diff --git a/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java b/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java index 456ad830..f27ec5c5 100644 --- a/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java +++ b/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java @@ -26,7 +26,7 @@ import android.support.v17.leanback.widget.GuidanceStylist.Guidance; import android.support.v17.leanback.widget.GuidedAction; import android.text.format.DateUtils; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Program; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.data.ScheduledRecording; @@ -50,7 +50,7 @@ public class DvrAlreadyScheduledFragment extends DvrGuidedStepFragment { public void onAttach(Context context) { super.onAttach(context); mProgram = getArguments().getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM); - DvrManager dvrManager = TvSingletons.getSingletons(context).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(context).getDvrManager(); mDuplicate = dvrManager.getScheduledRecording( mProgram.getTitle(), diff --git a/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java index 24a6fcd3..e247b82b 100644 --- a/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java +++ b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java @@ -22,7 +22,7 @@ import android.support.v17.leanback.app.GuidedStepFragment; import android.support.v17.leanback.widget.GuidanceStylist.Guidance; import android.support.v17.leanback.widget.GuidedAction; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.dvr.DvrManager; @@ -42,7 +42,7 @@ public class DvrChannelRecordDurationOptionFragment extends DvrGuidedStepFragmen if (args != null) { long channelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID); mChannel = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getChannelDataManager() .getChannel(channelId); } @@ -90,7 +90,7 @@ public class DvrChannelRecordDurationOptionFragment extends DvrGuidedStepFragmen @Override public void onTrackedGuidedActionClicked(GuidedAction action) { - DvrManager dvrManager = TvSingletons.getSingletons(getContext()).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); long duration = mDurations.get((int) action.getId()); long startTimeMs = System.currentTimeMillis(); long endTimeMs = System.currentTimeMillis() + duration; diff --git a/src/com/android/tv/dvr/ui/DvrConflictFragment.java b/src/com/android/tv/dvr/ui/DvrConflictFragment.java index 641f86c1..80011acd 100644 --- a/src/com/android/tv/dvr/ui/DvrConflictFragment.java +++ b/src/com/android/tv/dvr/ui/DvrConflictFragment.java @@ -29,7 +29,7 @@ import android.view.View; import android.view.ViewGroup; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.data.Program; @@ -149,7 +149,7 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { private String getScheduleTitle(ScheduledRecording schedule) { if (schedule.getType() == ScheduledRecording.TYPE_TIMED) { Channel channel = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getChannelDataManager() .getChannel(schedule.getChannelId()); if (channel != null) { @@ -179,7 +179,7 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { List<ScheduledRecording> conflicts = null; if (input != null) { conflicts = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrManager() .getConflictingSchedules(mProgram); } @@ -227,7 +227,7 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { Bundle args = getArguments(); long channelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID); mChannel = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getChannelDataManager() .getChannel(channelId); SoftPreconditions.checkArgument(mChannel != null); @@ -238,7 +238,7 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { mStartTimeMs = args.getLong(DvrHalfSizedDialogFragment.KEY_START_TIME_MS); mEndTimeMs = args.getLong(DvrHalfSizedDialogFragment.KEY_END_TIME_MS); conflicts = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrManager() .getConflictingSchedules( mChannel.getId(), mStartTimeMs, mEndTimeMs); diff --git a/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java index 793bd01b..8524e1ea 100644 --- a/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java +++ b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java @@ -26,13 +26,14 @@ import android.support.v17.leanback.widget.VerticalGridView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import com.android.tv.ApplicationSingletons; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.TvSingletons; -import com.android.tv.common.recording.RecordingStorageStatusManager; +import com.android.tv.TvApplication; import com.android.tv.dialog.HalfSizedDialogFragment.OnActionClickListener; import com.android.tv.dialog.SafeDismissDialogFragment; import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrStorageStatusManager; import java.util.List; public abstract class DvrGuidedStepFragment extends TrackedGuidedStepFragment { @@ -55,7 +56,7 @@ public abstract class DvrGuidedStepFragment extends TrackedGuidedStepFragment { @Override public void onAttach(Context context) { super.onAttach(context); - TvSingletons singletons = TvSingletons.getSingletons(context); + ApplicationSingletons singletons = TvApplication.getSingletons(context); mDvrManager = singletons.getDvrManager(); } @@ -114,8 +115,8 @@ public abstract class DvrGuidedStepFragment extends TrackedGuidedStepFragment { } /** - * The inner guided step fragment for {@link - * com.android.tv.dvr.ui.DvrHalfSizedDialogFragment .DvrNoFreeSpaceErrorDialogFragment}. + * The inner guided step fragment for {@link com.android.tv.dvr.ui.DvrHalfSizedDialogFragment + * .DvrNoFreeSpaceErrorDialogFragment}. */ public static class DvrNoFreeSpaceErrorFragment extends DvrGuidedStepFragment { @Override @@ -154,8 +155,7 @@ public abstract class DvrGuidedStepFragment extends TrackedGuidedStepFragment { } /** - * The inner guided step fragment for {@link - * com.android.tv.dvr.ui.DvrHalfSizedDialogFragment + * The inner guided step fragment for {@link com.android.tv.dvr.ui.DvrHalfSizedDialogFragment * .DvrSmallSizedStorageErrorDialogFragment}. */ public static class DvrSmallSizedStorageErrorFragment extends DvrGuidedStepFragment { @@ -166,7 +166,7 @@ public abstract class DvrGuidedStepFragment extends TrackedGuidedStepFragment { getResources() .getString( R.string.dvr_error_small_sized_storage_description, - RecordingStorageStatusManager.MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES + DvrStorageStatusManager.MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES / 1024 / 1024 / 1024); diff --git a/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java b/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java index 6fba4d98..ad26a5c2 100644 --- a/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java +++ b/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java @@ -23,7 +23,7 @@ import android.os.Bundle; import android.support.v17.leanback.widget.GuidanceStylist.Guidance; import android.support.v17.leanback.widget.GuidedAction; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.dvr.ui.browse.DvrBrowseActivity; import java.util.ArrayList; @@ -102,7 +102,7 @@ public class DvrInsufficientSpaceErrorFragment extends DvrGuidedStepFragment { Activity activity = getActivity(); actions.add( new GuidedAction.Builder(activity).clickAction(GuidedAction.ACTION_ID_OK).build()); - if (TvSingletons.getSingletons(getContext()).getDvrManager().hasValidItems()) { + if (TvApplication.getSingletons(getContext()).getDvrManager().hasValidItems()) { actions.add( new GuidedAction.Builder(activity) .id(ACTION_VIEW_RECENT_RECORDINGS) diff --git a/src/com/android/tv/dvr/ui/DvrPrioritySettingsFragment.java b/src/com/android/tv/dvr/ui/DvrPrioritySettingsFragment.java index 5bb97e90..03124260 100644 --- a/src/com/android/tv/dvr/ui/DvrPrioritySettingsFragment.java +++ b/src/com/android/tv/dvr/ui/DvrPrioritySettingsFragment.java @@ -16,11 +16,9 @@ package com.android.tv.dvr.ui; -import android.annotation.TargetApi; import android.app.FragmentManager; import android.content.Context; import android.graphics.Typeface; -import android.os.Build; import android.os.Bundle; import android.support.v17.leanback.widget.GuidanceStylist.Guidance; import android.support.v17.leanback.widget.GuidedAction; @@ -29,7 +27,7 @@ import android.view.View; import android.widget.ImageView; import android.widget.TextView; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.DvrScheduleManager; @@ -38,8 +36,6 @@ import java.util.ArrayList; import java.util.List; /** Fragment for DVR series recording settings. */ -@TargetApi(Build.VERSION_CODES.N) -@SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated public class DvrPrioritySettingsFragment extends TrackedGuidedStepFragment { /** Name of series recording id starting the fragment. Type: Long */ public static final String COME_FROM_SERIES_RECORDING_ID = "series_recording_id"; @@ -66,7 +62,7 @@ public class DvrPrioritySettingsFragment extends TrackedGuidedStepFragment { .setPriority(Long.MAX_VALUE) .setId(ONE_TIME_RECORDING_ID) .build()); - DvrDataManager dvrDataManager = TvSingletons.getSingletons(context).getDvrDataManager(); + DvrDataManager dvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); long comeFromSeriesRecordingId = getArguments().getLong(COME_FROM_SERIES_RECORDING_ID, -1); for (SeriesRecording series : dvrDataManager.getSeriesRecordings()) { if (series.getState() == SeriesRecording.STATE_SERIES_NORMAL @@ -131,7 +127,7 @@ public class DvrPrioritySettingsFragment extends TrackedGuidedStepFragment { public void onTrackedGuidedActionClicked(GuidedAction action) { long actionId = action.getId(); if (actionId == ACTION_ID_SAVE) { - DvrManager dvrManager = TvSingletons.getSingletons(getContext()).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); int size = mSeriesRecordings.size(); for (int i = 1; i < size; ++i) { long priority = DvrScheduleManager.suggestSeriesPriority(size - i); diff --git a/src/com/android/tv/dvr/ui/DvrScheduleFragment.java b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java index 5251e140..854fea56 100644 --- a/src/com/android/tv/dvr/ui/DvrScheduleFragment.java +++ b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java @@ -27,7 +27,7 @@ import android.support.v17.leanback.widget.GuidanceStylist.Guidance; import android.support.v17.leanback.widget.GuidedAction; import android.text.format.DateUtils; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Program; import com.android.tv.dvr.DvrManager; @@ -63,18 +63,16 @@ public class DvrScheduleFragment extends DvrGuidedStepFragment { mProgram = args.getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM); mAddCurrentProgramToSeries = args.getBoolean(KEY_ADD_CURRENT_PROGRAM_TO_SERIES, false); } - DvrManager dvrManager = TvSingletons.getSingletons(getContext()).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); SoftPreconditions.checkArgument( mProgram != null && mProgram.isEpisodic(), TAG, - "The program should be episodic: %s ", - mProgram); + "The program should be episodic: " + mProgram); SeriesRecording seriesRecording = dvrManager.getSeriesRecording(mProgram); SoftPreconditions.checkArgument( seriesRecording == null || seriesRecording.isStopped(), TAG, - "The series recording should be stopped or null: %s", - seriesRecording); + "The series recording should be stopped or null: " + seriesRecording); super.onCreate(savedInstanceState); } @@ -146,7 +144,7 @@ public class DvrScheduleFragment extends DvrGuidedStepFragment { } } else if (action.getId() == ACTION_RECORD_SERIES) { SeriesRecording seriesRecording = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrDataManager() .getSeriesRecording(mProgram.getSeriesId()); if (seriesRecording == null) { @@ -161,7 +159,7 @@ public class DvrScheduleFragment extends DvrGuidedStepFragment { seriesRecording = SeriesRecording.buildFrom(seriesRecording) .setPriority( - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrScheduleManager() .suggestNewSeriesPriority()) .build(); diff --git a/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java index a2ae1f97..8b05cf1c 100644 --- a/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java @@ -20,7 +20,7 @@ import android.app.Activity; import android.os.Bundle; import android.support.v17.leanback.app.GuidedStepFragment; import com.android.tv.R; -import com.android.tv.Starter; +import com.android.tv.TvApplication; /** Activity to show details view in DVR. */ public class DvrSeriesDeletionActivity extends Activity { @@ -29,7 +29,7 @@ public class DvrSeriesDeletionActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); setContentView(R.layout.activity_dvr_series_settings); // Check savedInstanceState to prevent that activity is being showed with animation. diff --git a/src/com/android/tv/dvr/ui/DvrSeriesDeletionFragment.java b/src/com/android/tv/dvr/ui/DvrSeriesDeletionFragment.java index 685f0a58..5f2c3582 100644 --- a/src/com/android/tv/dvr/ui/DvrSeriesDeletionFragment.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesDeletionFragment.java @@ -27,7 +27,7 @@ import android.text.TextUtils; import android.view.ViewGroup.LayoutParams; import android.widget.Toast; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; @@ -67,9 +67,9 @@ public class DvrSeriesDeletionFragment extends GuidedStepFragment { mSeriesRecordingId = getArguments().getLong(DvrSeriesDeletionActivity.SERIES_RECORDING_ID, -1); SoftPreconditions.checkArgument(mSeriesRecordingId != -1); - mDvrDataManager = TvSingletons.getSingletons(context).getDvrDataManager(); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); mDvrWatchedPositionManager = - TvSingletons.getSingletons(context).getDvrWatchedPositionManager(); + TvApplication.getSingletons(context).getDvrWatchedPositionManager(); mRecordings = mDvrDataManager.getRecordedPrograms(mSeriesRecordingId); mOneLineActionHeight = getResources() @@ -166,7 +166,7 @@ public class DvrSeriesDeletionFragment extends GuidedStepFragment { } } if (!idsToDelete.isEmpty()) { - DvrManager dvrManager = TvSingletons.getSingletons(getActivity()).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager(); dvrManager.removeRecordedPrograms(idsToDelete); } Toast.makeText( diff --git a/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java b/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java index edb62c96..d600b54d 100644 --- a/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java @@ -23,7 +23,7 @@ import android.os.Bundle; import android.support.v17.leanback.widget.GuidanceStylist; import android.support.v17.leanback.widget.GuidedAction; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Program; import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.data.ScheduledRecording; @@ -68,7 +68,7 @@ public class DvrSeriesScheduledFragment extends DvrGuidedStepFragment { getArguments() .getBoolean(DvrSeriesScheduledDialogActivity.SHOW_VIEW_SCHEDULE_OPTION); mSeriesRecording = - TvSingletons.getSingletons(context) + TvApplication.getSingletons(context) .getDvrDataManager() .getSeriesRecording(seriesRecordingId); if (mSeriesRecording == null) { @@ -78,12 +78,12 @@ public class DvrSeriesScheduledFragment extends DvrGuidedStepFragment { mPrograms = (List<Program>) BigArguments.getArgument(SERIES_SCHEDULED_KEY_PROGRAMS); BigArguments.reset(); mSchedulesAddedCount = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrManager() .getAvailableScheduledRecording(mSeriesRecording.getId()) .size(); DvrScheduleManager dvrScheduleManager = - TvSingletons.getSingletons(context).getDvrScheduleManager(); + TvApplication.getSingletons(context).getDvrScheduleManager(); List<ScheduledRecording> conflictingRecordings = dvrScheduleManager.getConflictingSchedules(mSeriesRecording); mHasConflict = !conflictingRecordings.isEmpty(); diff --git a/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java index 1a51cf46..117f72d8 100644 --- a/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java @@ -21,7 +21,7 @@ import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.support.v17.leanback.app.GuidedStepFragment; import com.android.tv.R; -import com.android.tv.Starter; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; /** Activity to show details view in DVR. */ @@ -60,7 +60,7 @@ public class DvrSeriesSettingsActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); setContentView(R.layout.activity_dvr_series_settings); long seriesRecordingId = getIntent().getLongExtra(SERIES_RECORDING_ID, -1); diff --git a/src/com/android/tv/dvr/ui/DvrSeriesSettingsFragment.java b/src/com/android/tv/dvr/ui/DvrSeriesSettingsFragment.java index 9383058a..c44e44a3 100644 --- a/src/com/android/tv/dvr/ui/DvrSeriesSettingsFragment.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesSettingsFragment.java @@ -16,10 +16,8 @@ package com.android.tv.dvr.ui; -import android.annotation.TargetApi; import android.app.FragmentManager; import android.content.Context; -import android.os.Build; import android.os.Bundle; import android.support.v17.leanback.app.GuidedStepFragment; import android.support.v17.leanback.widget.GuidanceStylist.Guidance; @@ -27,7 +25,7 @@ import android.support.v17.leanback.widget.GuidedAction; import android.support.v17.leanback.widget.GuidedActionsStylist; import android.util.LongSparseArray; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.Program; @@ -45,8 +43,6 @@ import java.util.List; import java.util.Set; /** Fragment for DVR series recording settings. */ -@TargetApi(Build.VERSION_CODES.N) -@SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated public class DvrSeriesSettingsFragment extends GuidedStepFragment implements DvrDataManager.SeriesRecordingListener { private static final String TAG = "SeriesSettingsFragment"; @@ -85,7 +81,7 @@ public class DvrSeriesSettingsFragment extends GuidedStepFragment public void onAttach(Context context) { super.onAttach(context); mBackStackCount = getFragmentManager().getBackStackEntryCount(); - mDvrDataManager = TvSingletons.getSingletons(context).getDvrDataManager(); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); mSeriesRecordingId = getArguments().getLong(DvrSeriesSettingsActivity.SERIES_RECORDING_ID); mSeriesRecording = mDvrDataManager.getSeriesRecording(mSeriesRecordingId); if (mSeriesRecording == null) { @@ -106,7 +102,7 @@ public class DvrSeriesSettingsFragment extends GuidedStepFragment } Set<Long> channelIds = new HashSet<>(); ChannelDataManager channelDataManager = - TvSingletons.getSingletons(context).getChannelDataManager(); + TvApplication.getSingletons(context).getChannelDataManager(); for (Program program : mPrograms) { long channelId = program.getChannelId(); if (channelIds.add(channelId)) { @@ -212,7 +208,7 @@ public class DvrSeriesSettingsFragment extends GuidedStepFragment if (mSelectedChannelId != Channel.INVALID_ID) { builder.setChannelId(mSelectedChannelId); } - DvrManager dvrManager = TvSingletons.getSingletons(getContext()).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); dvrManager.updateSeriesRecording(builder.build()); if (mCurrentProgram != null && (mChannelOption == SeriesRecording.OPTION_CHANNEL_ALL @@ -332,7 +328,7 @@ public class DvrSeriesSettingsFragment extends GuidedStepFragment recordingCandidates) .get(mSeriesRecordingId); if (!programsToSchedule.isEmpty()) { - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrManager() .addScheduleToSeriesRecording(mSeriesRecording, programsToSchedule); } diff --git a/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java b/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java index e93387ab..6f34e8a0 100644 --- a/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java +++ b/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java @@ -26,7 +26,7 @@ import android.support.annotation.NonNull; import android.support.v17.leanback.widget.GuidanceStylist.Guidance; import android.support.v17.leanback.widget.GuidedAction; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; import com.android.tv.dvr.data.ScheduledRecording; @@ -100,7 +100,7 @@ public class DvrStopRecordingFragment extends DvrGuidedStepFragment { dismissDialog(); return; } - mDvrDataManager = TvSingletons.getSingletons(context).getDvrDataManager(); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener); mStopReason = args.getInt(KEY_REASON); } diff --git a/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java index 99211fdb..3d84f48f 100644 --- a/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java +++ b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java @@ -25,8 +25,9 @@ import android.support.v17.leanback.widget.GuidedAction; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.data.ScheduledRecording; @@ -76,7 +77,7 @@ public class DvrStopSeriesRecordingFragment extends DvrGuidedStepFragment { @Override public void onTrackedGuidedActionClicked(GuidedAction action) { if (action.getId() == ACTION_STOP_SERIES_RECORDING) { - TvSingletons singletons = TvSingletons.getSingletons(getContext()); + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); DvrManager dvrManager = singletons.getDvrManager(); DvrDataManager dataManager = singletons.getDvrDataManager(); List<ScheduledRecording> toDelete = new ArrayList<>(); diff --git a/src/com/android/tv/dvr/ui/DvrUiHelper.java b/src/com/android/tv/dvr/ui/DvrUiHelper.java index 6373b30f..ae60f4a4 100644 --- a/src/com/android/tv/dvr/ui/DvrUiHelper.java +++ b/src/com/android/tv/dvr/ui/DvrUiHelper.java @@ -39,15 +39,14 @@ import android.widget.ImageView; import android.widget.Toast; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.recording.RecordingStorageStatusManager; -import com.android.tv.common.util.CommonUtils; import com.android.tv.data.BaseProgram; import com.android.tv.data.Channel; import com.android.tv.data.Program; import com.android.tv.dialog.HalfSizedDialogFragment; import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrStorageStatusManager; import com.android.tv.dvr.data.RecordedProgram; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.data.SeriesRecording; @@ -92,17 +91,17 @@ public class DvrUiHelper { */ public static void checkStorageStatusAndShowErrorMessage( Activity activity, String inputId, Runnable recordingRequestRunnable) { - if (CommonUtils.isBundledInput(inputId)) { - switch (TvSingletons.getSingletons(activity) - .getRecordingStorageStatusManager() + if (Utils.isBundledInput(inputId)) { + switch (TvApplication.getSingletons(activity) + .getDvrStorageStatusManager() .getDvrStorageStatus()) { - case RecordingStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL: + case DvrStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL: showDvrSmallSizedStorageErrorDialog(activity); return; - case RecordingStorageStatusManager.STORAGE_STATUS_MISSING: + case DvrStorageStatusManager.STORAGE_STATUS_MISSING: showDvrMissingStorageErrorDialog(activity); return; - case RecordingStorageStatusManager.STORAGE_STATUS_FREE_SPACE_INSUFFICIENT: + case DvrStorageStatusManager.STORAGE_STATUS_FREE_SPACE_INSUFFICIENT: showDvrNoFreeSpaceErrorDialog(activity, recordingRequestRunnable); return; } @@ -282,7 +281,7 @@ public class DvrUiHelper { if (program == null) { return false; } - DvrManager dvrManager = TvSingletons.getSingletons(activity).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(activity).getDvrManager(); if (!program.isEpisodic()) { // One time recording. dvrManager.addSchedule(program); @@ -393,7 +392,7 @@ public class DvrUiHelper { return; } List<ScheduledRecording> conflicts = - TvSingletons.getSingletons(context) + TvApplication.getSingletons(context) .getDvrManager() .getConflictingSchedulesForTune(channel.getId()); startSchedulesActivity(context, getEarliestScheduledRecording(conflicts)); @@ -444,7 +443,7 @@ public class DvrUiHelper { boolean showViewScheduleOptionInDialog, Program currentProgram) { SeriesRecording series = - TvSingletons.getSingletons(context) + TvApplication.getSingletons(context) .getDvrDataManager() .getSeriesRecording(seriesRecordingId); if (series == null) { diff --git a/src/com/android/tv/dvr/ui/TrackedGuidedStepFragment.java b/src/com/android/tv/dvr/ui/TrackedGuidedStepFragment.java index 0172f76f..0a24187a 100644 --- a/src/com/android/tv/dvr/ui/TrackedGuidedStepFragment.java +++ b/src/com/android/tv/dvr/ui/TrackedGuidedStepFragment.java @@ -19,7 +19,7 @@ package com.android.tv.dvr.ui; import android.content.Context; import android.support.v17.leanback.app.GuidedStepFragment; import android.support.v17.leanback.widget.GuidedAction; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.Tracker; /** A {@link GuidedStepFragment} with {@link Tracker} for analytics. */ @@ -29,7 +29,7 @@ public abstract class TrackedGuidedStepFragment extends GuidedStepFragment { @Override public void onAttach(Context context) { super.onAttach(context); - mTracker = TvSingletons.getSingletons(context).getAnalytics().getDefaultTracker(); + mTracker = TvApplication.getSingletons(context).getAnalytics().getDefaultTracker(); } @Override diff --git a/src/com/android/tv/dvr/ui/browse/CurrentRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/CurrentRecordingDetailsFragment.java index 7e7e1f75..22246e5a 100644 --- a/src/com/android/tv/dvr/ui/browse/CurrentRecordingDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/CurrentRecordingDetailsFragment.java @@ -22,7 +22,7 @@ import android.support.v17.leanback.widget.Action; import android.support.v17.leanback.widget.OnActionClickedListener; import android.support.v17.leanback.widget.SparseArrayObjectAdapter; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dialog.HalfSizedDialogFragment; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; @@ -66,7 +66,7 @@ public class CurrentRecordingDetailsFragment extends RecordingDetailsFragment { @Override public void onAttach(Context context) { super.onAttach(context); - mDvrDataManger = TvSingletons.getSingletons(context).getDvrDataManager(); + mDvrDataManger = TvApplication.getSingletons(context).getDvrDataManager(); mDvrDataManger.addScheduledRecordingListener(mScheduledRecordingListener); } @@ -100,7 +100,7 @@ public class CurrentRecordingDetailsFragment extends RecordingDetailsFragment { public void onActionClick(long actionId) { if (actionId == DvrStopRecordingFragment.ACTION_STOP) { DvrManager dvrManager = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrManager(); dvrManager.stopRecording(getRecording()); getActivity().finish(); diff --git a/src/com/android/tv/dvr/ui/browse/DetailsContent.java b/src/com/android/tv/dvr/ui/browse/DetailsContent.java index 70903373..9f588aa3 100644 --- a/src/com/android/tv/dvr/ui/browse/DetailsContent.java +++ b/src/com/android/tv/dvr/ui/browse/DetailsContent.java @@ -21,7 +21,7 @@ import android.media.tv.TvContract; import android.support.annotation.Nullable; import android.text.TextUtils; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Channel; import com.android.tv.dvr.data.RecordedProgram; import com.android.tv.dvr.data.ScheduledRecording; @@ -76,7 +76,7 @@ class DetailsContent { static DetailsContent createFromScheduledRecording( Context context, ScheduledRecording scheduledRecording) { Channel channel = - TvSingletons.getSingletons(context) + TvApplication.getSingletons(context) .getChannelDataManager() .getChannel(scheduledRecording.getChannelId()); String description = @@ -278,7 +278,7 @@ class DetailsContent { /** Builds details content. */ public DetailsContent build(Context context) { Channel channel = - TvSingletons.getSingletons(context) + TvApplication.getSingletons(context) .getChannelDataManager() .getChannel(mChannelId); if (mDetailsContent.mTitle == null) { diff --git a/src/com/android/tv/dvr/ui/browse/DetailsViewBackgroundHelper.java b/src/com/android/tv/dvr/ui/browse/DetailsViewBackgroundHelper.java index 849360b8..5a058454 100644 --- a/src/com/android/tv/dvr/ui/browse/DetailsViewBackgroundHelper.java +++ b/src/com/android/tv/dvr/ui/browse/DetailsViewBackgroundHelper.java @@ -57,7 +57,6 @@ class DetailsViewBackgroundHelper { public DetailsViewBackgroundHelper(Activity activity) { mBackgroundManager = BackgroundManager.getInstance(activity); mBackgroundManager.attach(activity.getWindow()); - mBackgroundManager.setAutoReleaseOnStop(false); } /** Sets the given image to background. */ diff --git a/src/com/android/tv/dvr/ui/browse/DvrBrowseActivity.java b/src/com/android/tv/dvr/ui/browse/DvrBrowseActivity.java index 6cc1c7a1..f208b5e8 100644 --- a/src/com/android/tv/dvr/ui/browse/DvrBrowseActivity.java +++ b/src/com/android/tv/dvr/ui/browse/DvrBrowseActivity.java @@ -21,7 +21,7 @@ import android.content.Intent; import android.media.tv.TvInputManager; import android.os.Bundle; import com.android.tv.R; -import com.android.tv.Starter; +import com.android.tv.TvApplication; /** {@link android.app.Activity} for DVR UI. */ public class DvrBrowseActivity extends Activity { @@ -29,7 +29,7 @@ public class DvrBrowseActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); setContentView(R.layout.dvr_main); mFragment = (DvrBrowseFragment) getFragmentManager().findFragmentById(R.id.dvr_frame); diff --git a/src/com/android/tv/dvr/ui/browse/DvrBrowseFragment.java b/src/com/android/tv/dvr/ui/browse/DvrBrowseFragment.java index 90326a8b..f8a54ef0 100644 --- a/src/com/android/tv/dvr/ui/browse/DvrBrowseFragment.java +++ b/src/com/android/tv/dvr/ui/browse/DvrBrowseFragment.java @@ -16,9 +16,7 @@ package com.android.tv.dvr.ui.browse; -import android.annotation.TargetApi; import android.content.Context; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.support.v17.leanback.app.BrowseFragment; @@ -31,8 +29,9 @@ import android.support.v17.leanback.widget.TitleViewAdapter; import android.util.Log; import android.view.View; import android.view.ViewTreeObserver.OnGlobalFocusChangeListener; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.GenreItems; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener; @@ -52,8 +51,6 @@ import java.util.HashMap; import java.util.List; /** {@link BrowseFragment} for DVR functions. */ -@TargetApi(Build.VERSION_CODES.N) -@SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated public class DvrBrowseFragment extends BrowseFragment implements RecordedProgramListener, ScheduledRecordingListener, @@ -171,7 +168,7 @@ public class DvrBrowseFragment extends BrowseFragment if (DEBUG) Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); Context context = getContext(); - TvSingletons singletons = TvSingletons.getSingletons(context); + ApplicationSingletons singletons = TvApplication.getSingletons(context); mDvrDataManager = singletons.getDvrDataManager(); mDvrScheudleManager = singletons.getDvrScheduleManager(); mPresenterSelector = diff --git a/src/com/android/tv/dvr/ui/browse/DvrDetailsActivity.java b/src/com/android/tv/dvr/ui/browse/DvrDetailsActivity.java index 2659c3f3..a953f1d2 100644 --- a/src/com/android/tv/dvr/ui/browse/DvrDetailsActivity.java +++ b/src/com/android/tv/dvr/ui/browse/DvrDetailsActivity.java @@ -23,7 +23,7 @@ import android.transition.Transition; import android.transition.Transition.TransitionListener; import android.view.View; import com.android.tv.R; -import com.android.tv.Starter; +import com.android.tv.TvApplication; import com.android.tv.dialog.PinDialogFragment; /** Activity to show details view in DVR. */ @@ -59,7 +59,7 @@ public class DvrDetailsActivity extends Activity implements PinDialogFragment.On @Override public void onCreate(Bundle savedInstanceState) { - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); setContentView(R.layout.activity_dvr_details); long recordId = getIntent().getLongExtra(RECORDING_ID, -1); diff --git a/src/com/android/tv/dvr/ui/browse/DvrDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/DvrDetailsFragment.java index 209fc6e1..f03f3f58 100644 --- a/src/com/android/tv/dvr/ui/browse/DvrDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/DvrDetailsFragment.java @@ -37,9 +37,8 @@ import android.support.v17.leanback.widget.VerticalGridView; import android.text.TextUtils; import android.widget.Toast; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.CommonUtils; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.dialog.PinDialogFragment; @@ -49,6 +48,7 @@ import com.android.tv.dvr.ui.DvrUiHelper; import com.android.tv.parental.ParentalControlSettings; import com.android.tv.util.ImageLoader; import com.android.tv.util.ToastUtils; +import com.android.tv.util.Utils; import java.io.File; abstract class DvrDetailsFragment extends DetailsFragment { @@ -195,7 +195,7 @@ abstract class DvrDetailsFragment extends DetailsFragment { } protected void startPlayback(RecordedProgram recordedProgram, long seekTimeMs) { - if (CommonUtils.isInBundledPackageSet(recordedProgram.getPackageName()) + if (Utils.isInBundledPackageSet(recordedProgram.getPackageName()) && !isDataUriAccessible(recordedProgram.getDataUri())) { // Since cleaning RecordedProgram from forgotten storage will take some time, // ignore playback until cleaning is finished. @@ -207,7 +207,7 @@ abstract class DvrDetailsFragment extends DetailsFragment { } long programId = recordedProgram.getId(); ParentalControlSettings parental = - TvSingletons.getSingletons(getActivity()) + TvApplication.getSingletons(getActivity()) .getTvInputManagerHelper() .getParentalControlSettings(); if (!parental.isParentalControlsEnabled()) { @@ -215,7 +215,7 @@ abstract class DvrDetailsFragment extends DetailsFragment { return; } ChannelDataManager channelDataManager = - TvSingletons.getSingletons(getActivity()).getChannelDataManager(); + TvApplication.getSingletons(getActivity()).getChannelDataManager(); Channel channel = channelDataManager.getChannel(recordedProgram.getChannelId()); if (channel != null && channel.isLocked()) { checkPinToPlay(recordedProgram, seekTimeMs); diff --git a/src/com/android/tv/dvr/ui/browse/DvrItemPresenter.java b/src/com/android/tv/dvr/ui/browse/DvrItemPresenter.java index 1e5f6935..4298d86a 100644 --- a/src/com/android/tv/dvr/ui/browse/DvrItemPresenter.java +++ b/src/com/android/tv/dvr/ui/browse/DvrItemPresenter.java @@ -31,9 +31,8 @@ import java.util.Set; /** * An abstract class to present DVR items in {@link RecordingCardView}, which is mainly used in * {@link DvrBrowseFragment}. DVR items might include: {@link - * com.android.tv.dvr.data.ScheduledRecording}, {@link - * com.android.tv.dvr.data.RecordedProgram}, and {@link - * com.android.tv.dvr.data.SeriesRecording}. + * com.android.tv.dvr.data.ScheduledRecording}, {@link com.android.tv.dvr.data.RecordedProgram}, and + * {@link com.android.tv.dvr.data.SeriesRecording}. */ public abstract class DvrItemPresenter<T> extends Presenter { protected final Context mContext; diff --git a/src/com/android/tv/dvr/ui/browse/FullSchedulesCardPresenter.java b/src/com/android/tv/dvr/ui/browse/FullSchedulesCardPresenter.java index af0f24c0..88133331 100644 --- a/src/com/android/tv/dvr/ui/browse/FullSchedulesCardPresenter.java +++ b/src/com/android/tv/dvr/ui/browse/FullSchedulesCardPresenter.java @@ -20,7 +20,7 @@ import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.dvr.ui.DvrUiHelper; import com.android.tv.util.Utils; @@ -50,7 +50,7 @@ class FullSchedulesCardPresenter extends DvrItemPresenter<Object> { cardView.setTitle(mCardTitleText); cardView.setImage(mIconDrawable); List<ScheduledRecording> scheduledRecordings = - TvSingletons.getSingletons(mContext) + TvApplication.getSingletons(mContext) .getDvrDataManager() .getAvailableScheduledRecordings(); int fullDays = 0; diff --git a/src/com/android/tv/dvr/ui/browse/RecordedProgramDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/RecordedProgramDetailsFragment.java index 47b1a198..3b3401b2 100644 --- a/src/com/android/tv/dvr/ui/browse/RecordedProgramDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/RecordedProgramDetailsFragment.java @@ -23,7 +23,7 @@ import android.support.v17.leanback.widget.Action; import android.support.v17.leanback.widget.OnActionClickedListener; import android.support.v17.leanback.widget.SparseArrayObjectAdapter; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.DvrWatchedPositionManager; @@ -44,7 +44,7 @@ public class RecordedProgramDetailsFragment extends DvrDetailsFragment @Override public void onCreate(Bundle savedInstanceState) { - mDvrDataManager = TvSingletons.getSingletons(getContext()).getDvrDataManager(); + mDvrDataManager = TvApplication.getSingletons(getContext()).getDvrDataManager(); mDvrDataManager.addRecordedProgramListener(this); super.onCreate(savedInstanceState); } @@ -52,7 +52,7 @@ public class RecordedProgramDetailsFragment extends DvrDetailsFragment @Override public void onCreateInternal() { mDvrWatchedPositionManager = - TvSingletons.getSingletons(getActivity()).getDvrWatchedPositionManager(); + TvApplication.getSingletons(getActivity()).getDvrWatchedPositionManager(); setDetailsOverviewRow( DetailsContent.createFromRecordedProgram(getContext(), mRecordedProgram)); } @@ -139,7 +139,7 @@ public class RecordedProgramDetailsFragment extends DvrDetailsFragment mRecordedProgram.getId())); } else if (action.getId() == ACTION_DELETE_RECORDING) { DvrManager dvrManager = - TvSingletons.getSingletons(getActivity()).getDvrManager(); + TvApplication.getSingletons(getActivity()).getDvrManager(); dvrManager.removeRecordedProgram(mRecordedProgram); getActivity().finish(); } diff --git a/src/com/android/tv/dvr/ui/browse/RecordedProgramPresenter.java b/src/com/android/tv/dvr/ui/browse/RecordedProgramPresenter.java index e2db3ac4..aad1cc6a 100644 --- a/src/com/android/tv/dvr/ui/browse/RecordedProgramPresenter.java +++ b/src/com/android/tv/dvr/ui/browse/RecordedProgramPresenter.java @@ -19,7 +19,7 @@ package com.android.tv.dvr.ui.browse; import android.content.Context; import android.media.tv.TvInputManager; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrWatchedPositionManager; import com.android.tv.dvr.DvrWatchedPositionManager.WatchedPositionChangedListener; import com.android.tv.dvr.data.RecordedProgram; @@ -95,7 +95,7 @@ public class RecordedProgramPresenter extends DvrItemPresenter<RecordedProgram> mTodayString = mContext.getString(R.string.dvr_date_today); mYesterdayString = mContext.getString(R.string.dvr_date_yesterday); mDvrWatchedPositionManager = - TvSingletons.getSingletons(mContext).getDvrWatchedPositionManager(); + TvApplication.getSingletons(mContext).getDvrWatchedPositionManager(); mProgressBarColor = mContext.getResources().getColor(R.color.play_controls_progress_bar_watched); mShowEpisodeTitle = showEpisodeTitle; diff --git a/src/com/android/tv/dvr/ui/browse/RecordingCardView.java b/src/com/android/tv/dvr/ui/browse/RecordingCardView.java index 0a204c14..edee5d3a 100644 --- a/src/com/android/tv/dvr/ui/browse/RecordingCardView.java +++ b/src/com/android/tv/dvr/ui/browse/RecordingCardView.java @@ -37,8 +37,8 @@ import com.android.tv.ui.ViewUtils; import com.android.tv.util.ImageLoader; /** - * A CardView for displaying info about a {@link com.android.tv.dvr.data.ScheduledRecording} - * or {@link RecordedProgram} or {@link com.android.tv.dvr.data.SeriesRecording}. + * A CardView for displaying info about a {@link com.android.tv.dvr.data.ScheduledRecording} or + * {@link RecordedProgram} or {@link com.android.tv.dvr.data.SeriesRecording}. */ public class RecordingCardView extends BaseCardView { // This value should be the same with diff --git a/src/com/android/tv/dvr/ui/browse/RecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/RecordingDetailsFragment.java index e4d95630..c8f1c785 100644 --- a/src/com/android/tv/dvr/ui/browse/RecordingDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/RecordingDetailsFragment.java @@ -18,7 +18,7 @@ package com.android.tv.dvr.ui.browse; import android.os.Bundle; import android.support.v17.leanback.app.DetailsFragment; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.data.ScheduledRecording; /** {@link DetailsFragment} for recordings in DVR. */ @@ -35,7 +35,7 @@ abstract class RecordingDetailsFragment extends DvrDetailsFragment { protected boolean onLoadRecordingDetails(Bundle args) { long scheduledRecordingId = args.getLong(DvrDetailsActivity.RECORDING_ID); mRecording = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrDataManager() .getScheduledRecording(scheduledRecordingId); return mRecording != null; diff --git a/src/com/android/tv/dvr/ui/browse/ScheduledRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/ScheduledRecordingDetailsFragment.java index 0765117d..b3e6ebb3 100644 --- a/src/com/android/tv/dvr/ui/browse/ScheduledRecordingDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/ScheduledRecordingDetailsFragment.java @@ -22,7 +22,7 @@ import android.support.v17.leanback.widget.Action; import android.support.v17.leanback.widget.OnActionClickedListener; import android.support.v17.leanback.widget.SparseArrayObjectAdapter; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.ui.DvrUiHelper; @@ -37,7 +37,7 @@ public class ScheduledRecordingDetailsFragment extends RecordingDetailsFragment @Override public void onCreate(Bundle savedInstance) { - mDvrManager = TvSingletons.getSingletons(getContext()).getDvrManager(); + mDvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); mHideViewSchedule = getArguments().getBoolean(DvrDetailsActivity.HIDE_VIEW_SCHEDULE); super.onCreate(savedInstance); } diff --git a/src/com/android/tv/dvr/ui/browse/ScheduledRecordingPresenter.java b/src/com/android/tv/dvr/ui/browse/ScheduledRecordingPresenter.java index f1ed52c8..fa948447 100644 --- a/src/com/android/tv/dvr/ui/browse/ScheduledRecordingPresenter.java +++ b/src/com/android/tv/dvr/ui/browse/ScheduledRecordingPresenter.java @@ -19,7 +19,7 @@ package com.android.tv.dvr.ui.browse; import android.content.Context; import android.os.Handler; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.util.Utils; @@ -100,7 +100,7 @@ class ScheduledRecordingPresenter extends DvrItemPresenter<ScheduledRecording> { public ScheduledRecordingPresenter(Context context) { super(context); - mDvrManager = TvSingletons.getSingletons(mContext).getDvrManager(); + mDvrManager = TvApplication.getSingletons(mContext).getDvrManager(); mProgressBarColor = mContext.getResources() .getColor(R.color.play_controls_recording_icon_color_on_focus); diff --git a/src/com/android/tv/dvr/ui/browse/SeriesRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/SeriesRecordingDetailsFragment.java index 2cd191a7..48bc9cbd 100644 --- a/src/com/android/tv/dvr/ui/browse/SeriesRecordingDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/SeriesRecordingDetailsFragment.java @@ -33,7 +33,7 @@ import android.support.v17.leanback.widget.PresenterSelector; import android.support.v17.leanback.widget.SparseArrayObjectAdapter; import android.text.TextUtils; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.BaseProgram; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrWatchedPositionManager; @@ -73,7 +73,7 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment @Override public void onCreate(Bundle savedInstanceState) { - mDvrDataManager = TvSingletons.getSingletons(getActivity()).getDvrDataManager(); + mDvrDataManager = TvApplication.getSingletons(getActivity()).getDvrDataManager(); mWatchLabel = getString(R.string.dvr_detail_watch); mResumeLabel = getString(R.string.dvr_detail_series_resume); mWatchDrawable = getResources().getDrawable(R.drawable.lb_ic_play, null); @@ -84,7 +84,7 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment @Override protected void onCreateInternal() { mDvrWatchedPositionManager = - TvSingletons.getSingletons(getActivity()).getDvrWatchedPositionManager(); + TvApplication.getSingletons(getActivity()).getDvrWatchedPositionManager(); setDetailsOverviewRow(DetailsContent.createFromSeriesRecording(getContext(), mSeries)); setupRecordedProgramsRow(); mDvrDataManager.addSeriesRecordingListener(this); @@ -137,7 +137,7 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment protected boolean onLoadRecordingDetails(Bundle args) { long recordId = args.getLong(DvrDetailsActivity.RECORDING_ID); mSeries = - TvSingletons.getSingletons(getActivity()) + TvApplication.getSingletons(getActivity()) .getDvrDataManager() .getSeriesRecording(recordId); if (mSeries == null) { diff --git a/src/com/android/tv/dvr/ui/browse/SeriesRecordingPresenter.java b/src/com/android/tv/dvr/ui/browse/SeriesRecordingPresenter.java index 14f9dceb..02ce24ef 100644 --- a/src/com/android/tv/dvr/ui/browse/SeriesRecordingPresenter.java +++ b/src/com/android/tv/dvr/ui/browse/SeriesRecordingPresenter.java @@ -19,8 +19,9 @@ package com.android.tv.dvr.ui.browse; import android.content.Context; import android.media.tv.TvInputManager; import android.text.TextUtils; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrDataManager.RecordedProgramListener; import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; @@ -185,7 +186,7 @@ class SeriesRecordingPresenter extends DvrItemPresenter<SeriesRecording> { public SeriesRecordingPresenter(Context context) { super(context); - TvSingletons singletons = TvSingletons.getSingletons(context); + ApplicationSingletons singletons = TvApplication.getSingletons(context); mDvrDataManager = singletons.getDvrDataManager(); mDvrManager = singletons.getDvrManager(); mWatchedPositionManager = singletons.getDvrWatchedPositionManager(); diff --git a/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java index 84298bdf..42c7086a 100644 --- a/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java +++ b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java @@ -23,8 +23,9 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.data.ScheduledRecording; @@ -49,7 +50,7 @@ public abstract class BaseDvrSchedulesFragment extends DetailsFragment mRowsAdapter = onCreateRowsAdapter(presenterSelector); setAdapter(mRowsAdapter); mRowsAdapter.start(); - TvSingletons singletons = TvSingletons.getSingletons(getContext()); + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); singletons.getDvrDataManager().addScheduledRecordingListener(this); singletons.getDvrScheduleManager().addOnConflictStateChangeListener(this); mEmptyInfoScreenView = (TextView) getActivity().findViewById(R.id.empty_info_screen); @@ -95,7 +96,7 @@ public abstract class BaseDvrSchedulesFragment extends DetailsFragment @Override public void onDestroy() { - TvSingletons singletons = TvSingletons.getSingletons(getContext()); + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); singletons.getDvrScheduleManager().removeOnConflictStateChangeListener(this); singletons.getDvrDataManager().removeScheduledRecordingListener(this); mRowsAdapter.stop(); diff --git a/src/com/android/tv/dvr/ui/list/DvrSchedulesActivity.java b/src/com/android/tv/dvr/ui/list/DvrSchedulesActivity.java index 82b85630..11df780c 100644 --- a/src/com/android/tv/dvr/ui/list/DvrSchedulesActivity.java +++ b/src/com/android/tv/dvr/ui/list/DvrSchedulesActivity.java @@ -21,7 +21,7 @@ import android.app.ProgressDialog; import android.os.Bundle; import android.support.annotation.IntDef; import com.android.tv.R; -import com.android.tv.Starter; +import com.android.tv.TvApplication; import com.android.tv.data.Program; import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.provider.EpisodicProgramLoadTask; @@ -53,7 +53,7 @@ public class DvrSchedulesActivity extends Activity { @Override public void onCreate(final Bundle savedInstanceState) { - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); // Pass null to prevent automatically re-creating fragments super.onCreate(null); setContentView(R.layout.activity_dvr_schedules); diff --git a/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java index d376e358..6ec2e152 100644 --- a/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java +++ b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java @@ -30,8 +30,9 @@ import android.transition.Fade; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.Program; import com.android.tv.dvr.DvrDataManager; @@ -140,7 +141,7 @@ public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - TvSingletons singletons = TvSingletons.getSingletons(getContext()); + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); mChannelDataManager = singletons.getChannelDataManager(); mChannelDataManager.addListener(mChannelListener); mDvrDataManager = singletons.getDvrDataManager(); diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java index 1215c19a..8dd6c322 100644 --- a/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java +++ b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java @@ -26,7 +26,7 @@ import android.text.format.DateUtils; import android.util.ArraySet; import android.util.Log; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.data.ScheduledRecording; @@ -79,11 +79,11 @@ class ScheduleRowAdapter extends ArrayObjectAdapter { public void start() { clear(); List<ScheduledRecording> recordingList = - TvSingletons.getSingletons(mContext) + TvApplication.getSingletons(mContext) .getDvrDataManager() .getNonStartedScheduledRecordings(); recordingList.addAll( - TvSingletons.getSingletons(mContext).getDvrDataManager().getStartedRecordings()); + TvApplication.getSingletons(mContext).getDvrDataManager().getStartedRecordings()); Collections.sort( recordingList, ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR); long deadLine = Utils.getLastMillisecondOfDay(System.currentTimeMillis()); @@ -136,7 +136,7 @@ class ScheduleRowAdapter extends ArrayObjectAdapter { /** Stops schedules row adapter. */ public void stop() { mHandler.removeCallbacksAndMessages(null); - DvrManager dvrManager = TvSingletons.getSingletons(getContext()).getDvrManager(); + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); for (int i = 0; i < size(); i++) { if (get(i) instanceof ScheduleRow) { ScheduleRow row = (ScheduleRow) get(i); diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java index 5cab607a..67096e3b 100644 --- a/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java +++ b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java @@ -38,7 +38,7 @@ import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toast; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.dialog.HalfSizedDialogFragment; @@ -344,8 +344,8 @@ class ScheduleRowPresenter extends RowPresenter { setHeaderPresenter(null); setSelectEffectEnabled(false); mContext = context; - mDvrManager = TvSingletons.getSingletons(context).getDvrManager(); - mDvrScheduleManager = TvSingletons.getSingletons(context).getDvrScheduleManager(); + mDvrManager = TvApplication.getSingletons(context).getDvrManager(); + mDvrScheduleManager = TvApplication.getSingletons(context).getDvrScheduleManager(); mTunerConflictWillNotBeRecordedInfo = mContext.getString(R.string.dvr_schedules_tuner_conflict_will_not_be_recorded_info); mTunerConflictWillBePartiallyRecordedInfo = @@ -426,7 +426,7 @@ class ScheduleRowPresenter extends RowPresenter { switch (actions.length) { case 2: viewHolder.mSecondActionView.setImageResource(getImageForAction(actions[1])); - // fall through + // pass through case 1: viewHolder.mFirstActionView.setImageResource(getImageForAction(actions[0])); break; @@ -486,7 +486,7 @@ class ScheduleRowPresenter extends RowPresenter { private String getChannelNameText(ScheduleRow row) { Channel channel = - TvSingletons.getSingletons(mContext) + TvApplication.getSingletons(mContext) .getChannelDataManager() .getChannel(row.getChannelId()); return channel == null diff --git a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java index eb01aba2..03cc0a79 100644 --- a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java +++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java @@ -28,7 +28,7 @@ import android.view.ViewGroup; import android.view.animation.DecelerateInterpolator; import android.widget.TextView; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.ui.DvrUiHelper; import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow; @@ -158,11 +158,11 @@ abstract class SchedulesHeaderRowPresenter extends RowPresenter { SeriesRecording seriesRecording = SeriesRecording.buildFrom(header.getSeriesRecording()) .setPriority( - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrScheduleManager() .suggestNewSeriesPriority()) .build(); - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getDvrManager() .updateSeriesRecording(seriesRecording); DvrUiHelper.startSeriesSettingsActivity( diff --git a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java index 9a9c94ea..692c0f99 100644 --- a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java +++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java @@ -23,8 +23,9 @@ import android.os.Build; import android.support.v17.leanback.widget.ClassPresenterSelector; import android.util.ArrayMap; import android.util.Log; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Program; import com.android.tv.dvr.DvrDataManager; @@ -64,7 +65,7 @@ class SeriesScheduleRowAdapter extends ScheduleRowAdapter { } else { mInputId = null; } - TvSingletons singletons = TvSingletons.getSingletons(context); + ApplicationSingletons singletons = TvApplication.getSingletons(context); mDvrManager = singletons.getDvrManager(); mDataManager = singletons.getDvrDataManager(); setHasStableIds(true); diff --git a/src/com/android/tv/dvr/ui/playback/DvrPlaybackActivity.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackActivity.java index b8b19adc..29f2734d 100644 --- a/src/com/android/tv/dvr/ui/playback/DvrPlaybackActivity.java +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackActivity.java @@ -24,7 +24,7 @@ import android.net.Uri; import android.os.Bundle; import android.util.Log; import com.android.tv.R; -import com.android.tv.Starter; +import com.android.tv.TvApplication; import com.android.tv.dialog.PinDialogFragment.OnPinCheckedListener; import com.android.tv.dvr.data.RecordedProgram; import com.android.tv.util.Utils; @@ -39,7 +39,7 @@ public class DvrPlaybackActivity extends Activity implements OnPinCheckedListene @Override public void onCreate(Bundle savedInstanceState) { - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); if (DEBUG) Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); setIntent(createProgramIntent(getIntent())); diff --git a/src/com/android/tv/dvr/ui/playback/DvrPlaybackMediaSessionHelper.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackMediaSessionHelper.java index dd17b22d..3ff90aa4 100644 --- a/src/com/android/tv/dvr/ui/playback/DvrPlaybackMediaSessionHelper.java +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackMediaSessionHelper.java @@ -29,7 +29,7 @@ import android.os.AsyncTask; import android.support.annotation.Nullable; import android.text.TextUtils; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.dvr.DvrWatchedPositionManager; @@ -61,8 +61,8 @@ class DvrPlaybackMediaSessionHelper { mActivity = activity; mDvrPlayer = dvrPlayer; mDvrWatchedPositionManager = - TvSingletons.getSingletons(activity).getDvrWatchedPositionManager(); - mChannelDataManager = TvSingletons.getSingletons(activity).getChannelDataManager(); + TvApplication.getSingletons(activity).getDvrWatchedPositionManager(); + mChannelDataManager = TvApplication.getSingletons(activity).getChannelDataManager(); mDvrPlayer.setCallback( new DvrPlayer.DvrPlayerCallback() { @Override diff --git a/src/com/android/tv/dvr/ui/playback/DvrPlaybackOverlayFragment.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackOverlayFragment.java index d3374cfa..c5fccda2 100644 --- a/src/com/android/tv/dvr/ui/playback/DvrPlaybackOverlayFragment.java +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackOverlayFragment.java @@ -43,7 +43,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.BaseProgram; import com.android.tv.dialog.PinDialogFragment; import com.android.tv.dvr.DvrDataManager; @@ -116,9 +116,9 @@ public class DvrPlaybackOverlayFragment extends PlaybackFragment { .getResources() .getDimensionPixelOffset( R.dimen.dvr_playback_overlay_padding_top_no_secondary_row); - mDvrDataManager = TvSingletons.getSingletons(getActivity()).getDvrDataManager(); + mDvrDataManager = TvApplication.getSingletons(getActivity()).getDvrDataManager(); mContentRatingsManager = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getTvInputManagerHelper() .getContentRatingsManager(); if (!mDvrDataManager.isRecordedProgramLoadFinished()) { diff --git a/src/com/android/tv/experiments/ExperimentFlag.java b/src/com/android/tv/experiments/ExperimentFlag.java new file mode 100644 index 00000000..2963482e --- /dev/null +++ b/src/com/android/tv/experiments/ExperimentFlag.java @@ -0,0 +1,62 @@ +/* + * 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.experiments; + +import android.support.annotation.VisibleForTesting; + +/** Experiments return values based on user, device and other criteria. */ +public final class ExperimentFlag<T> { + + private static boolean sAllowOverrides = false; + + @VisibleForTesting + public static void initForTest() { + sAllowOverrides = true; + } + + /** Returns a boolean experiment */ + public static ExperimentFlag<Boolean> createFlag(boolean defaultValue) { + return new ExperimentFlag<>(defaultValue); + } + + private final T mDefaultValue; + + private T mOverrideValue = null; + private boolean mOverridden = false; + + private ExperimentFlag(T defaultValue) { + mDefaultValue = defaultValue; + } + + /** Returns value for this experiment */ + public T get() { + return sAllowOverrides && mOverridden ? mOverrideValue : mDefaultValue; + } + + @VisibleForTesting + public void override(T t) { + if (sAllowOverrides) { + mOverridden = true; + mOverrideValue = t; + } + } + + @VisibleForTesting + public void resetOverride() { + mOverridden = false; + } +} diff --git a/src/com/android/tv/experiments/Experiments.java b/src/com/android/tv/experiments/Experiments.java new file mode 100644 index 00000000..0bff384e --- /dev/null +++ b/src/com/android/tv/experiments/Experiments.java @@ -0,0 +1,42 @@ +/* + * 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.experiments; + +import static com.android.tv.experiments.ExperimentFlag.createFlag; + +import com.android.tv.common.BuildConfig; + +/** + * Set of experiments visible in AOSP. + * + * <p>This file is maintained by hand. + */ +public final class Experiments { + public static final ExperimentFlag<Boolean> CLOUD_EPG = createFlag(true); + + public static final ExperimentFlag<Boolean> ENABLE_UNRATED_CONTENT_SETTINGS = createFlag(false); + + /** + * Allow developer features such as the dev menu and other aids. + * + * <p>These features are available to select users(aka fishfooders) on production builds. + */ + public static final ExperimentFlag<Boolean> ENABLE_DEVELOPER_FEATURES = + createFlag(BuildConfig.ENG); + + private Experiments() {} +} diff --git a/src/com/android/tv/guide/ProgramGuide.java b/src/com/android/tv/guide/ProgramGuide.java index 33ab9ad7..20c3ccdf 100644 --- a/src/com/android/tv/guide/ProgramGuide.java +++ b/src/com/android/tv/guide/ProgramGuide.java @@ -44,12 +44,11 @@ import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; import android.view.accessibility.AccessibilityManager; import com.android.tv.ChannelTuner; +import com.android.tv.Features; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.TvFeatures; import com.android.tv.analytics.Tracker; import com.android.tv.common.WeakHandler; -import com.android.tv.common.util.DurationTimer; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.GenreItems; import com.android.tv.data.ProgramDataManager; @@ -57,6 +56,7 @@ import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.ui.HardwareLayerAnimatorListenerAdapter; import com.android.tv.ui.ViewUtils; +import com.android.tv.util.DurationTimer; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import java.util.ArrayList; @@ -165,7 +165,6 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { } }; - @SuppressWarnings("RestrictTo") public ProgramGuide( MainActivity activity, ChannelTuner channelTuner, @@ -237,7 +236,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { mSidePanelGridView.setWindowAlignmentOffsetPercent( VerticalGridView.WINDOW_ALIGN_OFFSET_PERCENT_DISABLED); - if (TvFeatures.EPG_SEARCH.isEnabled(mActivity)) { + if (Features.EPG_SEARCH.isEnabled(mActivity)) { mSearchOrb = (SearchOrbView) mContainer.findViewById(R.id.program_guide_side_panel_search_orb); diff --git a/src/com/android/tv/guide/ProgramItemView.java b/src/com/android/tv/guide/ProgramItemView.java index 9daa9f2f..c8c7ff2d 100644 --- a/src/com/android/tv/guide/ProgramItemView.java +++ b/src/com/android/tv/guide/ProgramItemView.java @@ -35,9 +35,10 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; +import com.android.tv.ApplicationSingletons; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.Tracker; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; @@ -90,7 +91,8 @@ public class ProgramItemView extends TextView { // do nothing return; } - TvSingletons singletons = TvSingletons.getSingletons(view.getContext()); + ApplicationSingletons singletons = + TvApplication.getSingletons(view.getContext()); Tracker tracker = singletons.getTracker(); tracker.sendEpgItemClicked(); final MainActivity tvActivity = (MainActivity) view.getContext(); @@ -206,7 +208,7 @@ public class ProgramItemView extends TextView { super(context, attrs, defStyle); setOnClickListener(ON_CLICKED); setOnFocusChangeListener(ON_FOCUS_CHANGED); - mDvrManager = TvSingletons.getSingletons(getContext()).getDvrManager(); + mDvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); } private void initIfNeeded() { diff --git a/src/com/android/tv/guide/ProgramTableAdapter.java b/src/com/android/tv/guide/ProgramTableAdapter.java index c77673a2..8dd14ca3 100644 --- a/src/com/android/tv/guide/ProgramTableAdapter.java +++ b/src/com/android/tv/guide/ProgramTableAdapter.java @@ -16,6 +16,8 @@ package com.android.tv.guide; +import static com.android.tv.util.ImageLoader.ImageLoaderCallback; + import android.animation.Animator; import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; @@ -40,7 +42,6 @@ import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.ViewParent; import android.view.ViewTreeObserver; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener; @@ -48,9 +49,9 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; +import com.android.tv.common.TvCommonUtils; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.util.CommonUtils; import com.android.tv.data.Channel; import com.android.tv.data.Program; import com.android.tv.data.Program.CriticScore; @@ -58,12 +59,10 @@ import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.guide.ProgramManager.TableEntriesUpdatedListener; - import com.android.tv.parental.ParentalControlSettings; import com.android.tv.ui.HardwareLayerAnimatorListenerAdapter; import com.android.tv.util.ImageCache; import com.android.tv.util.ImageLoader; -import com.android.tv.util.ImageLoader.ImageLoaderCallback; import com.android.tv.util.ImageLoader.LoadTvInputLogoTask; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; @@ -114,10 +113,10 @@ class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapter.Progr mContext = context; mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); - mTvInputManagerHelper = TvSingletons.getSingletons(context).getTvInputManagerHelper(); + mTvInputManagerHelper = TvApplication.getSingletons(context).getTvInputManagerHelper(); if (CommonFeatures.DVR.isEnabled(context)) { - mDvrManager = TvSingletons.getSingletons(context).getDvrManager(); - mDvrDataManager = TvSingletons.getSingletons(context).getDvrDataManager(); + mDvrManager = TvApplication.getSingletons(context).getDvrManager(); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); } else { mDvrManager = null; mDvrDataManager = null; @@ -315,7 +314,7 @@ class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapter.Progr new AccessibilityManager.AccessibilityStateChangeListener() { @Override public void onAccessibilityStateChanged(boolean enable) { - enable &= !CommonUtils.isRunningInTest(); + enable &= !TvCommonUtils.isRunningInTest(); mDetailView.setFocusable(enable); mChannelHeaderView.setFocusable(enable); } @@ -369,7 +368,7 @@ class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapter.Progr mInputLogoView = (ImageView) mContainer.findViewById(R.id.input_logo); boolean accessibilityEnabled = - mAccessibilityManager.isEnabled() && !CommonUtils.isRunningInTest(); + mAccessibilityManager.isEnabled() && !TvCommonUtils.isRunningInTest(); mDetailView.setFocusable(accessibilityEnabled); mChannelHeaderView.setFocusable(accessibilityEnabled); } @@ -449,7 +448,7 @@ class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapter.Progr if (newFocus == null) { return; } // When the accessibility service is enabled, focus might be put on channel's header - // or + // or // detail view, besides program items. if (newFocus == mChannelHeaderView) { mSelectedEntry = ((ProgramItemView) mProgramRow.getChildAt(0)).getTableEntry(); diff --git a/src/com/android/tv/license/LicenseUtils.java b/src/com/android/tv/license/LicenseUtils.java index ea774caa..1bae0c6a 100644 --- a/src/com/android/tv/license/LicenseUtils.java +++ b/src/com/android/tv/license/LicenseUtils.java @@ -21,7 +21,6 @@ import java.io.IOException; import java.io.InputStream; /** Utilities for showing open source licenses. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public final class LicenseUtils { public static final String RATING_SOURCE_FILE = "file:///android_asset/rating_sources.html"; diff --git a/src/com/android/tv/menu/ChannelsRowAdapter.java b/src/com/android/tv/menu/ChannelsRowAdapter.java index 2d86ec75..73ff6f3b 100644 --- a/src/com/android/tv/menu/ChannelsRowAdapter.java +++ b/src/com/android/tv/menu/ChannelsRowAdapter.java @@ -20,13 +20,15 @@ import android.content.Context; import android.content.Intent; import android.media.tv.TvInputInfo; import android.view.View; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.Tracker; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; import com.android.tv.dvr.DvrDataManager; import com.android.tv.recommendation.Recommender; +import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; import java.util.ArrayDeque; import java.util.ArrayList; @@ -100,10 +102,10 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channels Context context, Recommender recommender, int minCount, int maxCount) { super(context); mContext = context; - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - mTracker = tvSingletons.getTracker(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mTracker = appSingletons.getTracker(); if (CommonFeatures.DVR.isEnabled(context)) { - mDvrDataManager = tvSingletons.getDvrDataManager(); + mDvrDataManager = appSingletons.getDvrDataManager(); } else { mDvrDataManager = null; } @@ -229,14 +231,14 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channels } private boolean needToShowSetupItem() { - TvSingletons singletons = TvSingletons.getSingletons(mContext); - TvInputManagerHelper inputManager = singletons.getTvInputManagerHelper(); - return singletons.getSetupUtils().hasNewInput(inputManager); + TvInputManagerHelper inputManager = + TvApplication.getSingletons(mContext).getTvInputManagerHelper(); + return SetupUtils.getInstance(mContext).hasNewInput(inputManager); } private boolean needToShowDvrItem() { TvInputManagerHelper inputManager = - TvSingletons.getSingletons(mContext).getTvInputManagerHelper(); + TvApplication.getSingletons(mContext).getTvInputManagerHelper(); if (mDvrDataManager != null) { for (TvInputInfo info : inputManager.getTvInputInfos(true, true)) { if (info.canRecord()) { @@ -249,7 +251,7 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channels private boolean needToShowAppLinkItem() { TvInputManagerHelper inputManager = - TvSingletons.getSingletons(mContext).getTvInputManagerHelper(); + TvApplication.getSingletons(mContext).getTvInputManagerHelper(); Channel currentChannel = getMainActivity().getCurrentChannel(); return currentChannel != null && currentChannel.getAppLinkType(mContext) != Channel.APP_LINK_TYPE_NONE diff --git a/src/com/android/tv/menu/CustomizableOptionsRowAdapter.java b/src/com/android/tv/menu/CustomizableOptionsRowAdapter.java index 7da26916..9ec70d09 100644 --- a/src/com/android/tv/menu/CustomizableOptionsRowAdapter.java +++ b/src/com/android/tv/menu/CustomizableOptionsRowAdapter.java @@ -17,7 +17,7 @@ package com.android.tv.menu; import android.content.Context; -import com.android.tv.common.customization.CustomAction; +import com.android.tv.customization.CustomAction; import java.util.ArrayList; import java.util.List; diff --git a/src/com/android/tv/menu/Menu.java b/src/com/android/tv/menu/Menu.java index 0e081ba8..2b8a1fa6 100644 --- a/src/com/android/tv/menu/Menu.java +++ b/src/com/android/tv/menu/Menu.java @@ -30,15 +30,15 @@ import android.support.v17.leanback.widget.HorizontalGridView; import android.util.Log; import com.android.tv.ChannelTuner; import com.android.tv.R; +import com.android.tv.TvApplication; import com.android.tv.TvOptionsManager; -import com.android.tv.TvSingletons; import com.android.tv.analytics.Tracker; +import com.android.tv.common.TvCommonUtils; import com.android.tv.common.WeakHandler; -import com.android.tv.common.util.CommonUtils; -import com.android.tv.common.util.DurationTimer; import com.android.tv.menu.MenuRowFactory.PartnerRow; import com.android.tv.menu.MenuRowFactory.TvOptionsRow; import com.android.tv.ui.TunableTvView; +import com.android.tv.util.DurationTimer; import com.android.tv.util.ViewCache; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -139,7 +139,7 @@ public class Menu { OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) { mContext = context; mMenuView = menuView; - mTracker = TvSingletons.getSingletons(context).getTracker(); + mTracker = TvApplication.getSingletons(context).getTracker(); mMenuUpdater = new MenuUpdater(this, tvView, optionsManager); Resources res = context.getResources(); mShowDurationMillis = res.getInteger(R.integer.menu_show_duration); @@ -328,7 +328,7 @@ public class Menu { @VisibleForTesting void disableAnimationForTest() { - if (!CommonUtils.isRunningInTest()) { + if (!TvCommonUtils.isRunningInTest()) { throw new RuntimeException("Animation may only be enabled/disabled during tests."); } mAnimationDisabledForTest = true; diff --git a/src/com/android/tv/menu/MenuLayoutManager.java b/src/com/android/tv/menu/MenuLayoutManager.java index a600f704..e652463e 100644 --- a/src/com/android/tv/menu/MenuLayoutManager.java +++ b/src/com/android/tv/menu/MenuLayoutManager.java @@ -366,7 +366,7 @@ public class MenuLayoutManager { return; } boolean indexValid = Utils.isIndexValid(mMenuRowViews, position); - SoftPreconditions.checkArgument(indexValid, TAG, "position %s ", position); + SoftPreconditions.checkArgument(indexValid, TAG, "position " + position); if (!indexValid) { return; } @@ -419,7 +419,7 @@ public class MenuLayoutManager { return; } boolean newIndexValid = Utils.isIndexValid(mMenuRowViews, position); - SoftPreconditions.checkArgument(newIndexValid, TAG, "position %s", position); + SoftPreconditions.checkArgument(newIndexValid, TAG, "position " + position); if (!newIndexValid) { return; } diff --git a/src/com/android/tv/menu/MenuRowFactory.java b/src/com/android/tv/menu/MenuRowFactory.java index 048d725d..5424e6f6 100644 --- a/src/com/android/tv/menu/MenuRowFactory.java +++ b/src/com/android/tv/menu/MenuRowFactory.java @@ -21,8 +21,8 @@ import android.support.annotation.Nullable; import android.text.TextUtils; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.common.customization.CustomAction; -import com.android.tv.common.customization.CustomizationManager; +import com.android.tv.customization.CustomAction; +import com.android.tv.customization.TvCustomizationManager; import com.android.tv.ui.TunableTvView; import java.util.List; @@ -30,14 +30,14 @@ import java.util.List; public class MenuRowFactory { private final MainActivity mMainActivity; private final TunableTvView mTvView; - private final CustomizationManager mCustomizationManager; + private final TvCustomizationManager mTvCustomizationManager; /** A constructor. */ public MenuRowFactory(MainActivity mainActivity, TunableTvView tvView) { mMainActivity = mainActivity; mTvView = tvView; - mCustomizationManager = new CustomizationManager(mainActivity); - mCustomizationManager.initialize(); + mTvCustomizationManager = new TvCustomizationManager(mainActivity); + mTvCustomizationManager.initialize(); } /** Creates an object corresponding to the given {@code key}. */ @@ -50,8 +50,8 @@ public class MenuRowFactory { return new ChannelsRow(mMainActivity, menu, mMainActivity.getProgramDataManager()); } else if (PartnerRow.class.equals(key)) { List<CustomAction> customActions = - mCustomizationManager.getCustomActions(CustomizationManager.ID_PARTNER_ROW); - String title = mCustomizationManager.getPartnerRowTitle(); + mTvCustomizationManager.getCustomActions(TvCustomizationManager.ID_PARTNER_ROW); + String title = mTvCustomizationManager.getPartnerRowTitle(); if (customActions != null && !TextUtils.isEmpty(title)) { return new PartnerRow(mMainActivity, menu, title, customActions); } @@ -60,7 +60,8 @@ public class MenuRowFactory { return new TvOptionsRow( mMainActivity, menu, - mCustomizationManager.getCustomActions(CustomizationManager.ID_OPTIONS_ROW)); + mTvCustomizationManager.getCustomActions( + TvCustomizationManager.ID_OPTIONS_ROW)); } return null; } diff --git a/src/com/android/tv/menu/OptionsRowAdapter.java b/src/com/android/tv/menu/OptionsRowAdapter.java index ceffe861..593bede7 100644 --- a/src/com/android/tv/menu/OptionsRowAdapter.java +++ b/src/com/android/tv/menu/OptionsRowAdapter.java @@ -19,7 +19,7 @@ package com.android.tv.menu; import android.content.Context; import android.view.View; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.Tracker; import java.util.List; @@ -54,7 +54,7 @@ public abstract class OptionsRowAdapter extends ItemListRowView.ItemListAdapter< public OptionsRowAdapter(Context context) { super(context); - mTracker = TvSingletons.getSingletons(context).getTracker(); + mTracker = TvApplication.getSingletons(context).getTracker(); } /** Update action list and its content. */ diff --git a/src/com/android/tv/menu/PartnerOptionsRowAdapter.java b/src/com/android/tv/menu/PartnerOptionsRowAdapter.java index 9676fe4d..1a962640 100644 --- a/src/com/android/tv/menu/PartnerOptionsRowAdapter.java +++ b/src/com/android/tv/menu/PartnerOptionsRowAdapter.java @@ -17,7 +17,7 @@ package com.android.tv.menu; import android.content.Context; -import com.android.tv.common.customization.CustomAction; +import com.android.tv.customization.CustomAction; import java.util.Collections; import java.util.List; diff --git a/src/com/android/tv/menu/PlayControlsRowView.java b/src/com/android/tv/menu/PlayControlsRowView.java index 35f1d503..d9879e18 100644 --- a/src/com/android/tv/menu/PlayControlsRowView.java +++ b/src/com/android/tv/menu/PlayControlsRowView.java @@ -28,7 +28,7 @@ import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TimeShiftManager; import com.android.tv.TimeShiftManager.TimeShiftActionId; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; @@ -127,8 +127,8 @@ public class PlayControlsRowView extends MenuRowView { mCompactButtonMargin = res.getDimensionPixelSize(R.dimen.play_controls_button_compact_margin); if (CommonFeatures.DVR.isEnabled(context)) { - mDvrDataManager = TvSingletons.getSingletons(context).getDvrDataManager(); - mDvrManager = TvSingletons.getSingletons(context).getDvrManager(); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mDvrManager = TvApplication.getSingletons(context).getDvrManager(); } else { mDvrDataManager = null; mDvrManager = null; @@ -275,7 +275,7 @@ public class PlayControlsRowView extends MenuRowView { private void onRecordButtonClicked() { boolean isRecording = isCurrentChannelRecording(); Channel currentChannel = mMainActivity.getCurrentChannel(); - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getTracker() .sendMenuClicked( isRecording @@ -290,7 +290,7 @@ public class PlayControlsRowView extends MenuRowView { .show(); } else { Program program = - TvSingletons.getSingletons(mMainActivity) + TvApplication.getSingletons(mMainActivity) .getProgramDataManager() .getCurrentProgram(currentChannel.getId()); DvrUiHelper.checkStorageStatusAndShowErrorMessage( diff --git a/src/com/android/tv/menu/TvOptionsRowAdapter.java b/src/com/android/tv/menu/TvOptionsRowAdapter.java index 55affb59..d340d309 100644 --- a/src/com/android/tv/menu/TvOptionsRowAdapter.java +++ b/src/com/android/tv/menu/TvOptionsRowAdapter.java @@ -19,16 +19,16 @@ package com.android.tv.menu; import android.content.Context; import android.media.tv.TvTrackInfo; import android.support.annotation.VisibleForTesting; -import com.android.tv.TvFeatures; +import com.android.tv.Features; import com.android.tv.TvOptionsManager; -import com.android.tv.common.customization.CustomAction; -import com.android.tv.common.util.CommonUtils; +import com.android.tv.customization.CustomAction; import com.android.tv.data.DisplayMode; import com.android.tv.ui.TvViewUiManager; import com.android.tv.ui.sidepanel.ClosedCaptionFragment; import com.android.tv.ui.sidepanel.DeveloperOptionFragment; import com.android.tv.ui.sidepanel.DisplayModeFragment; import com.android.tv.ui.sidepanel.MultiAudioFragment; +import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.List; @@ -45,12 +45,12 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { List<MenuAction> actionList = new ArrayList<>(); actionList.add(MenuAction.SELECT_CLOSED_CAPTION_ACTION); actionList.add(MenuAction.SELECT_DISPLAY_MODE_ACTION); - if (TvFeatures.PICTURE_IN_PICTURE.isEnabled(getMainActivity())) { + if (Features.PICTURE_IN_PICTURE.isEnabled(getMainActivity())) { actionList.add(MenuAction.SYSTEMWIDE_PIP_ACTION); } actionList.add(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION); actionList.add(MenuAction.MORE_CHANNELS_ACTION); - if (CommonUtils.isDeveloper()) { + if (Utils.isDeveloper()) { actionList.add(MenuAction.DEV_ACTION); } actionList.add(MenuAction.SETTINGS_ACTION); diff --git a/src/com/android/tv/onboarding/NewSourcesFragment.java b/src/com/android/tv/onboarding/NewSourcesFragment.java index e6b247a0..eaf06990 100644 --- a/src/com/android/tv/onboarding/NewSourcesFragment.java +++ b/src/com/android/tv/onboarding/NewSourcesFragment.java @@ -24,14 +24,14 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.ui.setup.SetupActionHelper; +import com.android.tv.util.SetupUtils; /** A fragment for new channel source info/setup. */ public class NewSourcesFragment extends Fragment { /** The action category. */ - public static final String ACTION_CATEOGRY = - "com.android.tv.onboarding.NewSourcesFragment"; + public static final String ACTION_CATEOGRY = "com.android.tv.onboarding.NewSourcesFragment"; /** An action to show the setup screen. */ public static final int ACTION_SETUP = 1; /** An action to close this fragment. */ @@ -52,8 +52,9 @@ public class NewSourcesFragment extends Fragment { View view = inflater.inflate(R.layout.fragment_new_sources, container, false); initializeButton(view.findViewById(R.id.setup), ACTION_SETUP); initializeButton(view.findViewById(R.id.skip), ACTION_SKIP); - TvSingletons singletons = TvSingletons.getSingletons(getActivity()); - singletons.getSetupUtils().markAllInputsRecognized(singletons.getTvInputManagerHelper()); + SetupUtils.getInstance(getActivity()) + .markAllInputsRecognized( + TvApplication.getSingletons(getActivity()).getTvInputManagerHelper()); view.requestFocus(); return view; } diff --git a/src/com/android/tv/onboarding/OnboardingActivity.java b/src/com/android/tv/onboarding/OnboardingActivity.java index a1cf9de1..8d429a1d 100644 --- a/src/com/android/tv/onboarding/OnboardingActivity.java +++ b/src/com/android/tv/onboarding/OnboardingActivity.java @@ -26,15 +26,16 @@ import android.media.tv.TvInputInfo; import android.os.Bundle; import android.support.annotation.NonNull; import android.widget.Toast; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; import com.android.tv.SetupPassthroughActivity; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; +import com.android.tv.common.TvCommonUtils; import com.android.tv.common.ui.setup.SetupActivity; import com.android.tv.common.ui.setup.SetupMultiPaneFragment; -import com.android.tv.common.util.CommonUtils; -import com.android.tv.common.util.PermissionUtils; import com.android.tv.data.ChannelDataManager; import com.android.tv.util.OnboardingUtils; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; @@ -49,13 +50,12 @@ public class OnboardingActivity extends SetupActivity { private ChannelDataManager mChannelDataManager; private TvInputManagerHelper mInputManager; - private SetupUtils mSetupUtils; private final ChannelDataManager.Listener mChannelListener = new ChannelDataManager.Listener() { @Override public void onLoadFinished() { mChannelDataManager.removeListener(this); - mSetupUtils.markNewChannelsBrowsable(); + SetupUtils.getInstance(OnboardingActivity.this).markNewChannelsBrowsable(); } @Override @@ -81,15 +81,14 @@ public class OnboardingActivity extends SetupActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - TvSingletons singletons = TvSingletons.getSingletons(this); + ApplicationSingletons singletons = TvApplication.getSingletons(this); mInputManager = singletons.getTvInputManagerHelper(); - mSetupUtils = singletons.getSetupUtils(); if (PermissionUtils.hasAccessAllEpg(this) || PermissionUtils.hasReadTvListings(this)) { mChannelDataManager = singletons.getChannelDataManager(); // Make the channels of the new inputs which have been setup outside Live TV // browsable. if (mChannelDataManager.isDbLoadFinished()) { - mSetupUtils.markNewChannelsBrowsable(); + SetupUtils.getInstance(this).markNewChannelsBrowsable(); } else { mChannelDataManager.addListener(mChannelListener); } @@ -180,7 +179,7 @@ public class OnboardingActivity extends SetupActivity { params.getString( SetupSourcesFragment.ACTION_PARAM_KEY_INPUT_ID); TvInputInfo input = mInputManager.getTvInputInfo(inputId); - Intent intent = CommonUtils.createSetupIntent(input); + Intent intent = TvCommonUtils.createSetupIntent(input); if (intent == null) { Toast.makeText( this, @@ -214,7 +213,7 @@ public class OnboardingActivity extends SetupActivity { case SetupMultiPaneFragment.ACTION_DONE: { ChannelDataManager manager = - TvSingletons.getSingletons(OnboardingActivity.this) + TvApplication.getSingletons(OnboardingActivity.this) .getChannelDataManager(); if (manager.getChannelCount() == 0) { finish(); diff --git a/src/com/android/tv/onboarding/SetupSourcesFragment.java b/src/com/android/tv/onboarding/SetupSourcesFragment.java index 3025538b..1ac6b27e 100644 --- a/src/com/android/tv/onboarding/SetupSourcesFragment.java +++ b/src/com/android/tv/onboarding/SetupSourcesFragment.java @@ -30,12 +30,14 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.ui.setup.SetupGuidedStepFragment; import com.android.tv.common.ui.setup.SetupMultiPaneFragment; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.TvInputNewComparator; +import com.android.tv.tuner.TunerInputController; import com.android.tv.ui.GuidedActionsStylistWithDivider; import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; @@ -46,8 +48,7 @@ import java.util.List; /** A fragment for channel source info/setup. */ public class SetupSourcesFragment extends SetupMultiPaneFragment { /** The action category for the actions which is fired from this fragment. */ - public static final String ACTION_CATEGORY = - "com.android.tv.onboarding.SetupSourcesFragment"; + public static final String ACTION_CATEGORY = "com.android.tv.onboarding.SetupSourcesFragment"; /** An action to open the merchant collection. */ public static final int ACTION_ONLINE_STORE = 1; /** @@ -70,7 +71,7 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = super.onCreateView(inflater, container, savedInstanceState); - TvSingletons.getSingletons(getActivity()).getTracker().sendScreenView(SETUP_TRACKER_LABEL); + TvApplication.getSingletons(getActivity()).getTracker().sendScreenView(SETUP_TRACKER_LABEL); return view; } @@ -189,18 +190,16 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { @Override public void onCreate(Bundle savedInstanceState) { Context context = getActivity(); - TvSingletons singletons = TvSingletons.getSingletons(context); - mInputManager = singletons.getTvInputManagerHelper(); - mChannelDataManager = singletons.getChannelDataManager(); - mSetupUtils = singletons.getSetupUtils(); + ApplicationSingletons app = TvApplication.getSingletons(context); + mInputManager = app.getTvInputManagerHelper(); + mChannelDataManager = app.getChannelDataManager(); + mSetupUtils = SetupUtils.getInstance(context); buildInputs(); mInputManager.addCallback(mInputCallback); mChannelDataManager.addListener(mChannelDataManagerListener); super.onCreate(savedInstanceState); mParentFragment = (SetupSourcesFragment) getParentFragment(); - singletons - .getTunerInputController() - .executeNetworkTunerDiscoveryAsyncTask(getContext()); + TunerInputController.executeNetworkTunerDiscoveryAsyncTask(getContext()); } @Override diff --git a/src/com/android/tv/onboarding/WelcomeFragment.java b/src/com/android/tv/onboarding/WelcomeFragment.java index 92b56e8d..d139314e 100644 --- a/src/com/android/tv/onboarding/WelcomeFragment.java +++ b/src/com/android/tv/onboarding/WelcomeFragment.java @@ -16,8 +16,6 @@ package com.android.tv.onboarding; -import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS; - import android.animation.Animator; import android.animation.AnimatorInflater; import android.animation.AnimatorListenerAdapter; @@ -26,18 +24,11 @@ import android.content.Context; import android.os.Bundle; import android.support.annotation.Nullable; import android.support.v17.leanback.app.OnboardingFragment; -import android.text.Editable; -import android.text.TextWatcher; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; -import android.view.View.AccessibilityDelegate; import android.view.ViewGroup; -import android.view.accessibility.AccessibilityEvent; -import android.view.accessibility.AccessibilityNodeInfo; -import android.widget.Button; import android.widget.ImageView; -import android.widget.TextView; import com.android.tv.R; import com.android.tv.common.ui.setup.SetupActionHelper; import com.android.tv.common.ui.setup.animation.SetupAnimationHelper; @@ -46,7 +37,7 @@ import java.util.List; /** A fragment for the onboarding welcome screen. */ public class WelcomeFragment extends OnboardingFragment { - public static final String ACTION_CATEGORY = "com.android.tv.onboarding.WelcomeFragment"; + public static final String ACTION_CATEGORY = "comgoogle.android.tv.onboarding.WelcomeFragment"; public static final int ACTION_NEXT = 1; private static final long START_DELAY_CLOUD_MS = 33; @@ -585,15 +576,8 @@ public class WelcomeFragment extends OnboardingFragment { private ImageView mTvContentView; private ImageView mArrowView; - private TextView mTitleView; - private Button mStartButton; - private View mPagingIndicator; - private Animator mAnimator; - private boolean mLogoAnimationFinished; - private boolean mTitleChanged; - public WelcomeFragment() { setExitTransition( new SetupAnimationHelper.TransitionBuilder() @@ -614,91 +598,14 @@ public class WelcomeFragment extends OnboardingFragment { mPageDescriptions = getResources().getStringArray(R.array.welcome_page_descriptions); } } + @Nullable @Override public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = super.onCreateView(inflater, container, savedInstanceState); setLogoResourceId(R.drawable.splash_logo); - mTitleView = view.findViewById(android.support.v17.leanback.R.id.title); - mPagingIndicator = view.findViewById(android.support.v17.leanback.R.id.page_indicator); - mStartButton = view.findViewById(android.support.v17.leanback.R.id.button_start); - - mStartButton.setAccessibilityDelegate(new AccessibilityDelegate() { - @Override - public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { - int type = event.getEventType(); - if (type == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED - || type == AccessibilityEvent.TYPE_VIEW_FOCUSED) { - if (!mTitleChanged || mTitleView.isAccessibilityFocused()) { - // Skip the event before the title is accessibility focused to avoid race - // conditions - return; - } - } - super.onInitializeAccessibilityEvent(host, event); - } - }); - - mPagingIndicator.setAccessibilityDelegate(new AccessibilityDelegate() { - @Override - public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { - int type = event.getEventType(); - if (type == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED - || type == AccessibilityEvent.TYPE_VIEW_FOCUSED) { - if (!mTitleChanged || mTitleView.isAccessibilityFocused()) { - // Skip the event before the title is accessibility focused to avoid race - // conditions - return; - } - } - super.onInitializeAccessibilityEvent(host, event); - } - }); - - mTitleView.setAccessibilityDelegate(new AccessibilityDelegate() { - @Override - public boolean performAccessibilityAction(View host, int action, Bundle args) { - if (action == ACTION_CLEAR_ACCESSIBILITY_FOCUS) { - if (!mTitleChanged || mTitleView.isAccessibilityFocused()) { - // Skip the event before the title is accessibility focused to avoid race - // conditions - return false; - } - } - return super.performAccessibilityAction(host, action, args); - } - }); - - mTitleView.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - mTitleChanged = false; - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(Editable s) { - if (!mTitleView.isAccessibilityFocused()) { - mTitleView.performAccessibilityAction( - AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null); - } else { - mTitleView.sendAccessibilityEvent( - AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); - } - mTitleChanged = true; - } - }); - return view; - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - if (savedInstanceState != null && mLogoAnimationFinished) { + if (savedInstanceState != null) { switch (getCurrentPageIndex()) { case 0: mTvContentView.setImageResource( @@ -720,6 +627,7 @@ public class WelcomeFragment extends OnboardingFragment { break; } } + return view; } @Override @@ -728,12 +636,6 @@ public class WelcomeFragment extends OnboardingFragment { } @Override - protected void onLogoAnimationFinished() { - super.onLogoAnimationFinished(); - mLogoAnimationFinished = true; - } - - @Override protected Animator onCreateEnterAnimation() { List<Animator> animators = new ArrayList<>(); // Cloud 1 @@ -831,7 +733,6 @@ public class WelcomeFragment extends OnboardingFragment { if (mAnimator != null) { mAnimator.cancel(); } - mTitleChanged = false; mArrowView.setVisibility(View.GONE); // TV screen hiding animator. Animator hideAnimator = diff --git a/src/com/android/tv/parental/ContentRatingsManager.java b/src/com/android/tv/parental/ContentRatingsManager.java index 32a1325b..a9c947c6 100644 --- a/src/com/android/tv/parental/ContentRatingsManager.java +++ b/src/com/android/tv/parental/ContentRatingsManager.java @@ -19,12 +19,12 @@ package com.android.tv.parental; import android.content.Context; import android.media.tv.TvContentRating; import android.media.tv.TvContentRatingSystemInfo; +import android.media.tv.TvInputManager; import android.support.annotation.Nullable; import android.text.TextUtils; import com.android.tv.R; import com.android.tv.parental.ContentRatingSystem.Rating; import com.android.tv.parental.ContentRatingSystem.SubRating; -import com.android.tv.util.TvInputManagerHelper; import java.util.ArrayList; import java.util.List; @@ -32,19 +32,19 @@ public class ContentRatingsManager { private final List<ContentRatingSystem> mContentRatingSystems = new ArrayList<>(); private final Context mContext; - private final TvInputManagerHelper.TvInputManagerInterface mTvInputManager; - public ContentRatingsManager( - Context context, TvInputManagerHelper.TvInputManagerInterface tvInputManager) { + public ContentRatingsManager(Context context) { mContext = context; - this.mTvInputManager = tvInputManager; } public void update() { mContentRatingSystems.clear(); + + TvInputManager manager = + (TvInputManager) mContext.getSystemService(Context.TV_INPUT_SERVICE); ContentRatingsParser parser = new ContentRatingsParser(mContext); - List<TvContentRatingSystemInfo> infos = mTvInputManager.getTvContentRatingSystemList(); + List<TvContentRatingSystemInfo> infos = manager.getTvContentRatingSystemList(); for (TvContentRatingSystemInfo info : infos) { List<ContentRatingSystem> list = parser.parse(info); if (list != null) { diff --git a/src/com/android/tv/parental/ContentRatingsParser.java b/src/com/android/tv/parental/ContentRatingsParser.java index 294e9463..3e645ce9 100644 --- a/src/com/android/tv/parental/ContentRatingsParser.java +++ b/src/com/android/tv/parental/ContentRatingsParser.java @@ -33,7 +33,6 @@ import java.util.List; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class ContentRatingsParser { private static final String TAG = "ContentRatingsParser"; private static final boolean DEBUG = false; diff --git a/src/com/android/tv/parental/ParentalControlSettings.java b/src/com/android/tv/parental/ParentalControlSettings.java index db1f0a4d..b9cf45b2 100644 --- a/src/com/android/tv/parental/ParentalControlSettings.java +++ b/src/com/android/tv/parental/ParentalControlSettings.java @@ -19,7 +19,7 @@ package com.android.tv.parental; import android.content.Context; import android.media.tv.TvContentRating; import android.media.tv.TvInputManager; -import com.android.tv.common.experiments.Experiments; +import com.android.tv.experiments.Experiments; import com.android.tv.parental.ContentRatingSystem.Rating; import com.android.tv.parental.ContentRatingSystem.SubRating; import com.android.tv.util.TvSettings; diff --git a/src/com/android/tv/perf/EventNames.java b/src/com/android/tv/perf/EventNames.java index 54745f3b..12488ddb 100644 --- a/src/com/android/tv/perf/EventNames.java +++ b/src/com/android/tv/perf/EventNames.java @@ -25,7 +25,6 @@ import java.lang.annotation.Retention; * Constants for performance event names. * * <p>Only constants are used to insure no PII is sent. - * */ public final class EventNames { diff --git a/src/com/android/tv/receiver/AudioCapabilitiesReceiver.java b/src/com/android/tv/receiver/AudioCapabilitiesReceiver.java index 3fb66245..90e087f0 100644 --- a/src/com/android/tv/receiver/AudioCapabilitiesReceiver.java +++ b/src/com/android/tv/receiver/AudioCapabilitiesReceiver.java @@ -24,10 +24,11 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import com.android.tv.TvSingletons; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.Analytics; import com.android.tv.analytics.Tracker; -import com.android.tv.common.util.SharedPreferencesUtils; +import com.android.tv.common.SharedPreferencesUtils; /** * Creates HDMI plug broadcast receiver, and reports AC3 passthrough capabilities to Google @@ -60,9 +61,9 @@ public final class AudioCapabilitiesReceiver { public AudioCapabilitiesReceiver( @NonNull Context context, @Nullable OnAc3PassthroughCapabilityChangeListener listener) { mContext = context; - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - mAnalytics = tvSingletons.getAnalytics(); - mTracker = tvSingletons.getTracker(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mAnalytics = appSingletons.getAnalytics(); + mTracker = appSingletons.getTracker(); mListener = listener; } diff --git a/src/com/android/tv/receiver/BootCompletedReceiver.java b/src/com/android/tv/receiver/BootCompletedReceiver.java index d8528bb5..b3ecb8e6 100644 --- a/src/com/android/tv/receiver/BootCompletedReceiver.java +++ b/src/com/android/tv/receiver/BootCompletedReceiver.java @@ -23,10 +23,9 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.os.Build; import android.util.Log; -import com.android.tv.Starter; +import com.android.tv.Features; import com.android.tv.TvActivity; -import com.android.tv.TvFeatures; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.dvr.recorder.DvrRecordingService; import com.android.tv.dvr.recorder.RecordingScheduler; import com.android.tv.recommendation.ChannelPreviewUpdater; @@ -52,12 +51,12 @@ public class BootCompletedReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - if (!TvSingletons.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { + if (!TvApplication.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); return; } if (DEBUG) Log.d(TAG, "boot completed " + intent); - Starter.start(context); + TvApplication.setCurrentRunningProcess(context, true); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ChannelPreviewUpdater.getInstance(context).updatePreviewDataForChannelsImmediately(); @@ -70,7 +69,7 @@ public class BootCompletedReceiver extends BroadcastReceiver { // Grant permission to already set up packages after the system has finished booting. SetupUtils.grantEpgPermissionToSetUpPackages(context); - if (TvFeatures.UNHIDE.isEnabled(context)) { + if (Features.UNHIDE.isEnabled(context)) { if (OnboardingUtils.isFirstBoot(context)) { // Enable the application if this is the first "unhide" feature is enabled just in // case when the app has been disabled before. @@ -85,7 +84,7 @@ public class BootCompletedReceiver extends BroadcastReceiver { } } - RecordingScheduler scheduler = TvSingletons.getSingletons(context).getRecordingScheduler(); + RecordingScheduler scheduler = TvApplication.getSingletons(context).getRecordingScheduler(); if (scheduler != null) { scheduler.updateAndStartServiceIfNeeded(); } diff --git a/src/com/android/tv/receiver/GlobalKeyReceiver.java b/src/com/android/tv/receiver/GlobalKeyReceiver.java index 0133d8ee..7c4117d4 100644 --- a/src/com/android/tv/receiver/GlobalKeyReceiver.java +++ b/src/com/android/tv/receiver/GlobalKeyReceiver.java @@ -23,9 +23,7 @@ import android.os.AsyncTask; import android.provider.Settings; import android.util.Log; import android.view.KeyEvent; -import com.android.tv.Starter; import com.android.tv.TvApplication; -import com.android.tv.TvSingletons; /** Handles global keys. */ public class GlobalKeyReceiver extends BroadcastReceiver { @@ -41,11 +39,11 @@ public class GlobalKeyReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - if (!TvSingletons.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { + if (!TvApplication.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); return; } - Starter.start(context); + TvApplication.setCurrentRunningProcess(context, true); Context appContext = context.getApplicationContext(); if (DEBUG) Log.d(TAG, "onReceive: " + intent); if (sUserSetupComplete) { diff --git a/src/com/android/tv/receiver/PackageIntentsReceiver.java b/src/com/android/tv/receiver/PackageIntentsReceiver.java index 3958f6bf..bd26c7b3 100644 --- a/src/com/android/tv/receiver/PackageIntentsReceiver.java +++ b/src/com/android/tv/receiver/PackageIntentsReceiver.java @@ -21,10 +21,7 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; import android.util.Log; -import com.android.tv.Starter; -import com.android.tv.TvFeatures; -import com.android.tv.TvSingletons; - +import com.android.tv.TvApplication; import com.android.tv.util.Partner; /** A class for handling the broadcast intents from PackageManager. */ @@ -33,12 +30,12 @@ public class PackageIntentsReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - if (!TvSingletons.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { + if (!TvApplication.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); return; } - Starter.start(context); - ((TvSingletons) context.getApplicationContext()).handleInputCountChanged(); + TvApplication.setCurrentRunningProcess(context, true); + ((TvApplication) context.getApplicationContext()).handleInputCountChanged(); Uri uri = intent.getData(); final String packageName = (uri != null ? uri.getSchemeSpecificPart() : null); diff --git a/src/com/android/tv/recommendation/ChannelPreviewUpdater.java b/src/com/android/tv/recommendation/ChannelPreviewUpdater.java index 5da802c4..d332e18a 100644 --- a/src/com/android/tv/recommendation/ChannelPreviewUpdater.java +++ b/src/com/android/tv/recommendation/ChannelPreviewUpdater.java @@ -28,8 +28,8 @@ import android.support.annotation.RequiresApi; import android.support.media.tv.TvContractCompat; import android.text.TextUtils; import android.util.Log; -import com.android.tv.Starter; -import com.android.tv.TvSingletons; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Channel; import com.android.tv.data.PreviewDataManager; import com.android.tv.data.PreviewProgramContent; @@ -46,7 +46,8 @@ import java.util.concurrent.TimeUnit; @RequiresApi(Build.VERSION_CODES.O) public class ChannelPreviewUpdater { private static final String TAG = "ChannelPreviewUpdater"; - private static final boolean DEBUG = false; + // STOPSHIP: set it to false. + private static final boolean DEBUG = true; private static final int UPATE_PREVIEW_PROGRAMS_JOB_ID = 1000001; private static final long ROUTINE_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10); @@ -99,10 +100,10 @@ public class ChannelPreviewUpdater { mRecommender.registerEvaluator(new RandomEvaluator(), 0.1, 0.1); mRecommender.registerEvaluator(new FavoriteChannelEvaluator(), 0.5, 0.5); mRecommender.registerEvaluator(new RoutineWatchEvaluator(), 1.0, 1.0); - TvSingletons tvSingleton = TvSingletons.getSingletons(context); - mPreviewDataManager = tvSingleton.getPreviewDataManager(); + ApplicationSingletons appSingleton = TvApplication.getSingletons(context); + mPreviewDataManager = appSingleton.getPreviewDataManager(); mParentalControlSettings = - tvSingleton.getTvInputManagerHelper().getParentalControlSettings(); + appSingleton.getTvInputManagerHelper().getParentalControlSettings(); } /** Starts the routine service for updating the preview programs. */ @@ -293,7 +294,7 @@ public class ChannelPreviewUpdater { @Override public void onCreate() { - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); if (DEBUG) Log.d(TAG, "ChannelPreviewUpdateService.onCreate"); mChannelPreviewUpdater = ChannelPreviewUpdater.getInstance(this); } diff --git a/src/com/android/tv/recommendation/ChannelRecord.java b/src/com/android/tv/recommendation/ChannelRecord.java index 34679452..812b9d3f 100644 --- a/src/com/android/tv/recommendation/ChannelRecord.java +++ b/src/com/android/tv/recommendation/ChannelRecord.java @@ -17,9 +17,8 @@ package com.android.tv.recommendation; import android.content.Context; -import android.support.annotation.GuardedBy; import android.support.annotation.VisibleForTesting; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Channel; import com.android.tv.data.Program; import com.android.tv.data.ProgramDataManager; @@ -30,10 +29,7 @@ public class ChannelRecord { // TODO: decide the value for max history size. @VisibleForTesting static final int MAX_HISTORY_SIZE = 100; private final Context mContext; - - @GuardedBy("this") private final Deque<WatchedProgram> mWatchHistory; - private Program mCurrentProgram; private Channel mChannel; private long mTotalWatchDurationMs; @@ -63,7 +59,7 @@ public class ChannelRecord { mInputRemoved = removed; } - public synchronized long getLastWatchEndTimeMs() { + public long getLastWatchEndTimeMs() { WatchedProgram p = mWatchHistory.peekLast(); return (p == null) ? 0 : p.getWatchEndTimeMs(); } @@ -72,7 +68,7 @@ public class ChannelRecord { long time = System.currentTimeMillis(); if (mCurrentProgram == null || mCurrentProgram.getEndTimeUtcMillis() < time) { ProgramDataManager manager = - TvSingletons.getSingletons(mContext).getProgramDataManager(); + TvApplication.getSingletons(mContext).getProgramDataManager(); mCurrentProgram = manager.getCurrentProgram(mChannel.getId()); } return mCurrentProgram; @@ -82,11 +78,11 @@ public class ChannelRecord { return mTotalWatchDurationMs; } - public final synchronized WatchedProgram[] getWatchHistory() { + public final WatchedProgram[] getWatchHistory() { return mWatchHistory.toArray(new WatchedProgram[mWatchHistory.size()]); } - public synchronized void logWatchHistory(WatchedProgram p) { + public void logWatchHistory(WatchedProgram p) { mWatchHistory.offer(p); mTotalWatchDurationMs += p.getWatchedDurationMs(); if (mWatchHistory.size() > MAX_HISTORY_SIZE) { diff --git a/src/com/android/tv/recommendation/NotificationService.java b/src/com/android/tv/recommendation/NotificationService.java index 201bb103..c4b321e1 100644 --- a/src/com/android/tv/recommendation/NotificationService.java +++ b/src/com/android/tv/recommendation/NotificationService.java @@ -40,10 +40,10 @@ import android.text.TextUtils; import android.util.Log; import android.util.SparseLongArray; import android.view.View; +import com.android.tv.ApplicationSingletons; import com.android.tv.MainActivityWrapper.OnCurrentChannelChangeListener; import com.android.tv.R; -import com.android.tv.Starter; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.WeakHandler; import com.android.tv.data.Channel; import com.android.tv.data.Program; @@ -122,7 +122,7 @@ public class NotificationService extends Service @Override public void onCreate() { if (DEBUG) Log.d(TAG, "onCreate"); - Starter.start(this); + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(); mCurrentNotificationCount = 0; mNotificationChannels = new long[NOTIFICATION_COUNT]; @@ -146,17 +146,17 @@ public class NotificationService extends Service getResources().getDimensionPixelOffset(R.dimen.notif_ch_logo_padding_bottom); mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - TvSingletons tvSingletons = TvSingletons.getSingletons(this); - mTvInputManagerHelper = tvSingletons.getTvInputManagerHelper(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(this); + mTvInputManagerHelper = appSingletons.getTvInputManagerHelper(); mHandlerThread = new HandlerThread("tv notification"); mHandlerThread.start(); mHandler = new NotificationHandler(mHandlerThread.getLooper(), this); mHandler.sendEmptyMessage(MSG_INITIALIZE_RECOMMENDER); // Just called for early initialization. - tvSingletons.getChannelDataManager(); - tvSingletons.getProgramDataManager(); - tvSingletons.getMainActivityWrapper().addOnCurrentChannelChangeListener(this); + appSingletons.getChannelDataManager(); + appSingletons.getProgramDataManager(); + appSingletons.getMainActivityWrapper().addOnCurrentChannelChangeListener(this); } @UiThread @@ -209,7 +209,7 @@ public class NotificationService extends Service @Override public void onDestroy() { - TvSingletons.getSingletons(this) + TvApplication.getSingletons(this) .getMainActivityWrapper() .removeOnCurrentChannelChangeListener(this); if (mRecommender != null) { diff --git a/src/com/android/tv/recommendation/RecommendationDataManager.java b/src/com/android/tv/recommendation/RecommendationDataManager.java index 8e8455fa..794ca7e2 100644 --- a/src/com/android/tv/recommendation/RecommendationDataManager.java +++ b/src/com/android/tv/recommendation/RecommendationDataManager.java @@ -33,13 +33,13 @@ import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.WeakHandler; -import com.android.tv.common.util.PermissionUtils; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.Program; import com.android.tv.data.WatchedHistoryManager; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.TvUriMatcher; import java.util.ArrayList; import java.util.Collection; @@ -50,7 +50,6 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class RecommendationDataManager implements WatchedHistoryManager.Listener { private static final int MSG_START = 1000; private static final int MSG_STOP = 1001; @@ -186,7 +185,7 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener mHandler = new RecommendationHandler(mHandlerThread.getLooper(), this); mMainHandler = new RecommendationMainHandler(Looper.getMainLooper(), this); mContentObserver = new RecommendationContentObserver(mHandler); - mChannelDataManager = TvSingletons.getSingletons(mContext).getChannelDataManager(); + mChannelDataManager = TvApplication.getSingletons(mContext).getChannelDataManager(); runOnMainThread( new Runnable() { @Override diff --git a/src/com/android/tv/recommendation/RecordedProgramPreviewUpdater.java b/src/com/android/tv/recommendation/RecordedProgramPreviewUpdater.java index edc23c53..2b17c510 100644 --- a/src/com/android/tv/recommendation/RecordedProgramPreviewUpdater.java +++ b/src/com/android/tv/recommendation/RecordedProgramPreviewUpdater.java @@ -21,7 +21,8 @@ import android.os.Build; import android.support.annotation.RequiresApi; import android.text.TextUtils; import android.util.Log; -import com.android.tv.TvSingletons; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.PreviewDataManager; import com.android.tv.data.PreviewProgramContent; import com.android.tv.dvr.DvrDataManager; @@ -33,10 +34,10 @@ import java.util.Set; /** Class to update the preview data for {@link RecordedProgram} */ @RequiresApi(Build.VERSION_CODES.O) -@SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated public class RecordedProgramPreviewUpdater { private static final String TAG = "RecordedProgramPreviewUpdater"; - private static final boolean DEBUG = false; + // STOPSHIP: set it to false. + private static final boolean DEBUG = true; private static final int RECOMMENDATION_COUNT = 6; @@ -57,9 +58,9 @@ public class RecordedProgramPreviewUpdater { private RecordedProgramPreviewUpdater(Context context) { mContext = context.getApplicationContext(); - TvSingletons tvSingletons = TvSingletons.getSingletons(mContext); - mPreviewDataManager = tvSingletons.getPreviewDataManager(); - mDvrDataManager = tvSingletons.getDvrDataManager(); + ApplicationSingletons applicationSingletons = TvApplication.getSingletons(mContext); + mPreviewDataManager = applicationSingletons.getPreviewDataManager(); + mDvrDataManager = applicationSingletons.getDvrDataManager(); mDvrDataManager.addRecordedProgramListener( new DvrDataManager.RecordedProgramListener() { @Override diff --git a/src/com/android/tv/search/DataManagerSearch.java b/src/com/android/tv/search/DataManagerSearch.java index 0d4a9dbc..a60355f4 100644 --- a/src/com/android/tv/search/DataManagerSearch.java +++ b/src/com/android/tv/search/DataManagerSearch.java @@ -26,7 +26,8 @@ import android.os.SystemClock; import android.support.annotation.MainThread; import android.text.TextUtils; import android.util.Log; -import com.android.tv.TvSingletons; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.Program; @@ -59,9 +60,9 @@ public class DataManagerSearch implements SearchInterface { DataManagerSearch(Context context) { mContext = context; mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - mChannelDataManager = tvSingletons.getChannelDataManager(); - mProgramDataManager = tvSingletons.getProgramDataManager(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mChannelDataManager = appSingletons.getChannelDataManager(); + mProgramDataManager = appSingletons.getProgramDataManager(); } @Override diff --git a/src/com/android/tv/search/LocalSearchProvider.java b/src/com/android/tv/search/LocalSearchProvider.java index 579811e7..dfd585c3 100644 --- a/src/com/android/tv/search/LocalSearchProvider.java +++ b/src/com/android/tv/search/LocalSearchProvider.java @@ -27,13 +27,13 @@ import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.CommonUtils; -import com.android.tv.common.util.PermissionUtils; +import com.android.tv.common.TvCommonUtils; import com.android.tv.perf.EventNames; import com.android.tv.perf.PerformanceMonitor; import com.android.tv.perf.TimerEvent; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.TvUriMatcher; import java.util.ArrayList; import java.util.Arrays; @@ -84,13 +84,13 @@ public class LocalSearchProvider extends ContentProvider { @Override public boolean onCreate() { - mPerformanceMonitor = TvSingletons.getSingletons(getContext()).getPerformanceMonitor(); + mPerformanceMonitor = TvApplication.getSingletons(getContext()).getPerformanceMonitor(); return true; } @VisibleForTesting void setSearchInterface(SearchInterface searchInterface) { - SoftPreconditions.checkState(CommonUtils.isRunningInTest()); + SoftPreconditions.checkState(TvCommonUtils.isRunningInTest()); mSearchInterface = searchInterface; } diff --git a/src/com/android/tv/search/ProgramGuideSearchFragment.java b/src/com/android/tv/search/ProgramGuideSearchFragment.java index d3fe1554..1ca86d22 100644 --- a/src/com/android/tv/search/ProgramGuideSearchFragment.java +++ b/src/com/android/tv/search/ProgramGuideSearchFragment.java @@ -40,8 +40,8 @@ import android.view.View; import android.view.ViewGroup; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.common.util.PermissionUtils; import com.android.tv.util.ImageLoader; +import com.android.tv.util.PermissionUtils; import java.util.List; public class ProgramGuideSearchFragment extends SearchFragment { @@ -83,8 +83,7 @@ public class ProgramGuideSearchFragment extends SearchFragment { mMainCardHeight, createImageLoaderCallback(cardView)); } else { - cardView.setMainImage( - mMainActivity.getDrawable(R.drawable.ic_live_channels_96x96)); + cardView.setMainImage(mMainActivity.getDrawable(R.drawable.ic_launcher)); } } @@ -169,7 +168,7 @@ public class ProgramGuideSearchFragment extends SearchFragment { View v = super.onCreateView(inflater, container, savedInstanceState); v.setBackgroundResource(R.color.program_guide_scrim); - setBadgeDrawable(mMainActivity.getDrawable(R.drawable.ic_live_channels_96x96)); + setBadgeDrawable(mMainActivity.getDrawable(R.drawable.ic_launcher)); setSearchResultProvider(mSearchResultProvider); setOnItemViewClickedListener(mItemClickedListener); return v; diff --git a/src/com/android/tv/search/TvProviderSearch.java b/src/com/android/tv/search/TvProviderSearch.java index a8640538..d6cf9cc9 100644 --- a/src/com/android/tv/search/TvProviderSearch.java +++ b/src/com/android/tv/search/TvProviderSearch.java @@ -33,8 +33,8 @@ import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.util.Log; import com.android.tv.common.TvContentRatingCache; -import com.android.tv.common.util.PermissionUtils; import com.android.tv.search.LocalSearchProvider.SearchResult; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.Collections; @@ -48,7 +48,6 @@ import java.util.Objects; import java.util.Set; /** An implementation of {@link SearchInterface} to search query from TvProvider directly. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class TvProviderSearch implements SearchInterface { private static final String TAG = "TvProviderSearch"; private static final boolean DEBUG = false; diff --git a/src/com/android/tv/setup/SystemSetupActivity.java b/src/com/android/tv/setup/SystemSetupActivity.java index 3fefc0c8..df25afa6 100644 --- a/src/com/android/tv/setup/SystemSetupActivity.java +++ b/src/com/android/tv/setup/SystemSetupActivity.java @@ -24,12 +24,13 @@ import android.content.Intent; import android.media.tv.TvInputInfo; import android.os.Bundle; import android.widget.Toast; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; import com.android.tv.SetupPassthroughActivity; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; +import com.android.tv.common.TvCommonUtils; import com.android.tv.common.ui.setup.SetupActivity; import com.android.tv.common.ui.setup.SetupMultiPaneFragment; -import com.android.tv.common.util.CommonUtils; import com.android.tv.onboarding.SetupSourcesFragment; import com.android.tv.util.OnboardingUtils; import com.android.tv.util.SetupUtils; @@ -51,7 +52,7 @@ public class SystemSetupActivity extends SetupActivity { finish(); return; } - TvSingletons singletons = TvSingletons.getSingletons(this); + ApplicationSingletons singletons = TvApplication.getSingletons(this); mInputManager = singletons.getTvInputManagerHelper(); } @@ -85,7 +86,7 @@ public class SystemSetupActivity extends SetupActivity { params.getString( SetupSourcesFragment.ACTION_PARAM_KEY_INPUT_ID); TvInputInfo input = mInputManager.getTvInputInfo(inputId); - Intent intent = CommonUtils.createSetupIntent(input); + Intent intent = TvCommonUtils.createSetupIntent(input); if (intent == null) { Toast.makeText( this, diff --git a/src/com/android/tv/tuner/ChannelScanFileParser.java b/src/com/android/tv/tuner/ChannelScanFileParser.java new file mode 100644 index 00000000..50153f89 --- /dev/null +++ b/src/com/android/tv/tuner/ChannelScanFileParser.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.tuner; + +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.TYPE_TUNER, frequency, modulation, null, radioFrequencyNumber); + } + + public static ScanChannel forFile(int frequency, String filename) { + return new ScanChannel(Channel.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/src/com/android/tv/tuner/DvbDeviceAccessor.java b/src/com/android/tv/tuner/DvbDeviceAccessor.java new file mode 100644 index 00000000..217433d2 --- /dev/null +++ b/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/src/com/android/tv/tuner/DvbTunerHal.java b/src/com/android/tv/tuner/DvbTunerHal.java new file mode 100644 index 00000000..8397400f --- /dev/null +++ b/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; + + protected 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/src/com/android/tv/tuner/TunerHal.java b/src/com/android/tv/tuner/TunerHal.java new file mode 100644 index 00000000..c8db73c3 --- /dev/null +++ b/src/com/android/tv/tuner/TunerHal.java @@ -0,0 +1,358 @@ +/* + * 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.customization.TvCustomizationManager; +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 { + 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. + */ + static boolean useBuiltInTuner(Context context) { + return getBuiltInTunerType(context) != 0; + } + + private static @BuiltInTunerType int getBuiltInTunerType(Context context) { + if (sBuiltInTunerType == null) { + sBuiltInTunerType = 0; + if (TvCustomizationManager.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. + */ + protected int openDvbFrontEndFd() { + return -1; + } + + /** + * Opens Linux DVB demux device. This method is called from native JNI and used only for + * DvbTunerHal. + */ + protected int openDvbDemuxFd() { + return -1; + } + + /** + * Opens Linux DVB dvr device. This method is called from native JNI and used only for + * DvbTunerHal. + */ + protected int openDvbDvrFd() { + return -1; + } +} diff --git a/src/com/android/tv/tuner/TunerInputController.java b/src/com/android/tv/tuner/TunerInputController.java index 02611bbf..829bec1c 100644 --- a/src/com/android/tv/tuner/TunerInputController.java +++ b/src/com/android/tv/tuner/TunerInputController.java @@ -17,9 +17,6 @@ package com.android.tv.tuner; import android.app.AlarmManager; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.ComponentName; @@ -27,14 +24,10 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbManager; import android.net.ConnectivityManager; import android.net.NetworkInfo; -import android.net.Uri; import android.os.AsyncTask; import android.os.Handler; import android.os.Looper; @@ -45,94 +38,72 @@ import android.support.annotation.NonNull; import android.text.TextUtils; import android.util.Log; import android.widget.Toast; +import com.android.tv.Features; import com.android.tv.R; -import com.android.tv.Starter; import com.android.tv.TvApplication; -import com.android.tv.TvSingletons; -import com.android.tv.common.BuildConfig; -import com.android.tv.common.util.SystemPropertiesProxy; - - -import com.android.tv.tuner.setup.BaseTunerSetupActivity; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.setup.TunerSetupActivity; +import com.android.tv.tuner.tvinput.TunerTvInputService; +import com.android.tv.tuner.util.SystemPropertiesProxy; import com.android.tv.tuner.util.TunerInputInfoUtils; import java.text.ParseException; import java.text.SimpleDateFormat; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.concurrent.TimeUnit; /** - * Controls the package visibility of {@link BaseTunerTvInputService}. + * Controls the package visibility of {@link TunerTvInputService}. * * <p>Listens to broadcast intent for {@link Intent#ACTION_BOOT_COMPLETED}, {@code * UsbManager.ACTION_USB_DEVICE_ATTACHED}, and {@code UsbManager.ACTION_USB_DEVICE_ATTACHED} to * update the connection status of the supported USB TV tuners. */ public class TunerInputController { - private static final boolean DEBUG = false; + private static final boolean DEBUG = true; private static final String TAG = "TunerInputController"; private static final String PREFERENCE_IS_NETWORK_TUNER_ATTACHED = "network_tuner"; private static final String SECURITY_PATCH_LEVEL_KEY = "ro.build.version.security_patch"; private static final String SECURITY_PATCH_LEVEL_FORMAT = "yyyy-MM-dd"; - private static final String PLAY_STORE_LINK_TEMPLATE = "market://details?id=%s"; /** Action of {@link Intent} to check network connection repeatedly when it is necessary. */ - private static final String CHECKING_NETWORK_TUNER_STATUS = - "com.android.tv.action.CHECKING_NETWORK_TUNER_STATUS"; + private static final String CHECKING_NETWORK_CONNECTION = + "com.android.tv.action.CHECKING_NETWORK_CONNECTION"; private static final String EXTRA_CHECKING_DURATION = "com.android.tv.action.extra.CHECKING_DURATION"; - private static final String EXTRA_DEVICE_IP = "com.android.tv.action.extra.DEVICE_IP"; private static final long INITIAL_CHECKING_DURATION_MS = TimeUnit.SECONDS.toMillis(10); private static final long MAXIMUM_CHECKING_DURATION_MS = TimeUnit.MINUTES.toMillis(10); - private static final String NOTIFICATION_CHANNEL_ID = "tuner_discovery_notification"; - // TODO: Load settings from XML file private static final TunerDevice[] TUNER_DEVICES = { new TunerDevice(0x2040, 0xb123, null), // WinTV-HVR-955Q new TunerDevice(0x07ca, 0x0837, null), // AverTV Volar Hybrid Q // WinTV-dualHD (bulk) will be supported after 2017 April security patch. new TunerDevice(0x2040, 0x826d, "2017-04-01"), // WinTV-dualHD (bulk) + // STOPSHIP: Add WinTV-soloHD (Isoc) temporary for test. Remove this after test complete. new TunerDevice(0x2040, 0x0264, null), }; private static final int MSG_ENABLE_INPUT_SERVICE = 1000; private static final long DVB_DRIVER_CHECK_DELAY_MS = 300; - private final ComponentName usbTunerComponent; - private final ComponentName networkTunerComponent; - private final ComponentName builtInTunerComponent; - private final Map<TunerDevice, ComponentName> mTunerServiceMapping = new HashMap<>(); - - private final Map<ComponentName, String> mTunerApplicationNames = new HashMap<>(); - private final Map<ComponentName, String> mNotificationMessages = new HashMap<>(); - private final Map<ComponentName, Bitmap> mNotificationLargeIcons = new HashMap<>(); - - private final CheckDvbDeviceHandler mHandler = new CheckDvbDeviceHandler(this); - - public TunerInputController(ComponentName embeddedTuner) { - usbTunerComponent = embeddedTuner; - networkTunerComponent = usbTunerComponent; - builtInTunerComponent = usbTunerComponent; - for (TunerDevice device : TUNER_DEVICES) { - mTunerServiceMapping.put(device, usbTunerComponent); - } - } - /** Checks status of USB devices to see if there are available USB tuners connected. */ - public void onCheckingUsbTunerStatus(Context context, String action) { - onCheckingUsbTunerStatus(context, action, mHandler); + public static void onCheckingUsbTunerStatus(Context context, String action) { + onCheckingUsbTunerStatus(context, action, new CheckDvbDeviceHandler()); } - private void onCheckingUsbTunerStatus( + private static void onCheckingUsbTunerStatus( Context context, String action, @NonNull CheckDvbDeviceHandler handler) { - Set<TunerDevice> connectedUsbTuners = getConnectedUsbTuners(context); + SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(context); + if (TunerHal.useBuiltInTuner(context)) { + enableTunerTvInputService(context, true, false, TunerHal.TUNER_TYPE_BUILT_IN); + return; + } + // Falls back to the below to check USB tuner devices. + boolean enabled = isUsbTunerConnected(context); handler.removeMessages(MSG_ENABLE_INPUT_SERVICE); - if (!connectedUsbTuners.isEmpty()) { + if (enabled) { // Need to check if DVB driver is accessible. Since the driver creation // could be happen after the USB event, delay the checking by // DVB_DRIVER_CHECK_DELAY_MS. @@ -140,37 +111,45 @@ public class TunerInputController { handler.obtainMessage(MSG_ENABLE_INPUT_SERVICE, context), DVB_DRIVER_CHECK_DELAY_MS); } else { - handleTunerStatusChanged( + if (sharedPreferences.getBoolean(PREFERENCE_IS_NETWORK_TUNER_ATTACHED, false)) { + // Since network tuner is attached, do not disable TunerTvInput, + // just updates the TvInputInfo. + TunerInputInfoUtils.updateTunerInputInfo(context); + return; + } + enableTunerTvInputService( context, false, - connectedUsbTuners, + false, TextUtils.equals(action, UsbManager.ACTION_USB_DEVICE_DETACHED) ? TunerHal.TUNER_TYPE_USB : null); } } - private void onNetworkTunerChanged(Context context, boolean enabled) { + private static void onNetworkTunerChanged(Context context, boolean enabled) { SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - if (sharedPreferences.contains(PREFERENCE_IS_NETWORK_TUNER_ATTACHED) - && sharedPreferences.getBoolean(PREFERENCE_IS_NETWORK_TUNER_ATTACHED, false) - == enabled) { - // the status is not changed - return; - } if (enabled) { + // Network tuner detection is initiated by UI. So the app should not + // be killed. sharedPreferences.edit().putBoolean(PREFERENCE_IS_NETWORK_TUNER_ATTACHED, true).apply(); + enableTunerTvInputService(context, true, true, TunerHal.TUNER_TYPE_NETWORK); } else { sharedPreferences .edit() .putBoolean(PREFERENCE_IS_NETWORK_TUNER_ATTACHED, false) .apply(); + if (!isUsbTunerConnected(context) && !TunerHal.useBuiltInTuner(context)) { + // Network tuner detection is initiated by UI. So the app should not + // be killed. + enableTunerTvInputService(context, false, true, TunerHal.TUNER_TYPE_NETWORK); + } else { + // Since USB tuner is attached, do not disable TunerTvInput, + // just updates the TvInputInfo. + TunerInputInfoUtils.updateTunerInputInfo(context); + } } - // Network tuner detection is initiated by UI. So the app should not - // be killed. - handleTunerStatusChanged( - context, true, getConnectedUsbTuners(context), TunerHal.TUNER_TYPE_NETWORK); } /** @@ -179,131 +158,75 @@ public class TunerInputController { * @param context {@link Context} instance * @return {@code true} if any tuner device we support is plugged in */ - private Set<TunerDevice> getConnectedUsbTuners(Context context) { + private static boolean isUsbTunerConnected(Context context) { UsbManager manager = (UsbManager) context.getSystemService(Context.USB_SERVICE); Map<String, UsbDevice> deviceList = manager.getDeviceList(); String currentSecurityLevel = SystemPropertiesProxy.getString(SECURITY_PATCH_LEVEL_KEY, null); - Set<TunerDevice> devices = new HashSet<>(); for (UsbDevice device : deviceList.values()) { if (DEBUG) { Log.d(TAG, "Device: " + device); } for (TunerDevice tuner : TUNER_DEVICES) { - if (tuner.equalsTo(device) && tuner.isSupported(currentSecurityLevel)) { + if (tuner.equals(device) && tuner.isSupported(currentSecurityLevel)) { Log.i(TAG, "Tuner found"); - devices.add(tuner); + return true; } } } - return devices; - } - - private void handleTunerStatusChanged( - Context context, - boolean forceDontKillApp, - Set<TunerDevice> connectedUsbTuners, - Integer triggerType) { - Map<ComponentName, Integer> serviceToEnable = new HashMap<>(); - Set<ComponentName> serviceToDisable = new HashSet<>(); - serviceToDisable.add(builtInTunerComponent); - serviceToDisable.add(networkTunerComponent); - if (TunerFeatures.TUNER.isEnabled(context)) { - // TODO: support both built-in tuner and other tuners at the same time? - if (TunerHal.useBuiltInTuner(context)) { - enableTunerTvInputService( - context, true, false, TunerHal.TUNER_TYPE_BUILT_IN, builtInTunerComponent); - return; - } - SharedPreferences sharedPreferences = - PreferenceManager.getDefaultSharedPreferences(context); - if (sharedPreferences.getBoolean(PREFERENCE_IS_NETWORK_TUNER_ATTACHED, false)) { - serviceToEnable.put(networkTunerComponent, TunerHal.TUNER_TYPE_NETWORK); - } - } - for (TunerDevice device : TUNER_DEVICES) { - if (TunerFeatures.TUNER.isEnabled(context) && connectedUsbTuners.contains(device)) { - serviceToEnable.put(mTunerServiceMapping.get(device), TunerHal.TUNER_TYPE_USB); - } else { - serviceToDisable.add(mTunerServiceMapping.get(device)); - } - } - serviceToDisable.removeAll(serviceToEnable.keySet()); - for (ComponentName serviceComponent : serviceToEnable.keySet()) { - if (isTunerPackageInstalled(context, serviceComponent)) { - enableTunerTvInputService( - context, - true, - forceDontKillApp, - serviceToEnable.get(serviceComponent), - serviceComponent); - } else { - sendNotificationToInstallPackage(context, serviceComponent); - } - } - for (ComponentName serviceComponent : serviceToDisable) { - if (isTunerPackageInstalled(context, serviceComponent)) { - enableTunerTvInputService( - context, false, forceDontKillApp, triggerType, serviceComponent); - } else { - cancelNotificationToInstallPackage(context, serviceComponent); - } - } + return false; } /** - * Enable/disable the component {@link BaseTunerTvInputService}. + * Enable/disable the component {@link TunerTvInputService}. * * @param context {@link Context} instance * @param enabled {@code true} to enable the service; otherwise {@code false} */ private static void enableTunerTvInputService( - Context context, - boolean enabled, - boolean forceDontKillApp, - Integer tunerType, - ComponentName serviceComponent) { + Context context, boolean enabled, boolean forceDontKillApp, Integer tunerType) { if (DEBUG) Log.d(TAG, "enableTunerTvInputService: " + enabled); PackageManager pm = context.getPackageManager(); + ComponentName componentName = new ComponentName(context, TunerTvInputService.class); + + // Don't kill app by enabling/disabling TvActivity. If LC is killed by enabling/disabling + // TvActivity, the following pm.setComponentEnabledSetting doesn't work. + ((TvApplication) context.getApplicationContext()) + .handleInputCountChanged(true, enabled, true); + // Since PackageManager.DONT_KILL_APP delays the operation by 10 seconds + // (PackageManagerService.BROADCAST_DELAY), we'd better avoid using it. It is used only + // when the LiveChannels app is active since we don't want to kill the running app. + int flags = + forceDontKillApp + || TvApplication.getSingletons(context) + .getMainActivityWrapper() + .isCreated() + ? PackageManager.DONT_KILL_APP + : 0; int newState = enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; - if (newState != pm.getComponentEnabledSetting(serviceComponent)) { - int flags = forceDontKillApp ? PackageManager.DONT_KILL_APP : 0; - if (serviceComponent.getPackageName().equals(context.getPackageName())) { - // Don't kill APP when handling input count changing. Or the following - // setComponentEnabledSetting() call won't work. - ((TvApplication) context.getApplicationContext()) - .handleInputCountChanged(true, enabled, true); - // Bundled input. Don't kill app if LiveChannels app is active since we don't want - // to kill the running app. - if (TvSingletons.getSingletons(context).getMainActivityWrapper().isCreated()) { - flags |= PackageManager.DONT_KILL_APP; - } - // Send/cancel the USB tuner TV input setup notification. - BaseTunerSetupActivity.onTvInputEnabled(context, enabled, tunerType); - if (!enabled && tunerType != null) { - if (tunerType == TunerHal.TUNER_TYPE_USB) { - Toast.makeText( - context, - R.string.msg_usb_tuner_disconnected, - Toast.LENGTH_SHORT) - .show(); - } else if (tunerType == TunerHal.TUNER_TYPE_NETWORK) { - Toast.makeText( - context, - R.string.msg_network_tuner_disconnected, - Toast.LENGTH_SHORT) - .show(); - } + if (newState != pm.getComponentEnabledSetting(componentName)) { + // Send/cancel the USB tuner TV input setup notification. + TunerSetupActivity.onTvInputEnabled(context, enabled, tunerType); + // Enable/disable the USB tuner TV input. + pm.setComponentEnabledSetting(componentName, newState, flags); + if (!enabled && tunerType != null) { + if (tunerType == TunerHal.TUNER_TYPE_USB) { + Toast.makeText(context, R.string.msg_usb_tuner_disconnected, Toast.LENGTH_SHORT) + .show(); + } else if (tunerType == TunerHal.TUNER_TYPE_NETWORK) { + Toast.makeText( + context, + R.string.msg_network_tuner_disconnected, + Toast.LENGTH_SHORT) + .show(); } } - // Enable/disable the USB tuner TV input. - pm.setComponentEnabledSetting(serviceComponent, newState, flags); if (DEBUG) Log.d(TAG, "Status updated:" + enabled); - } else if (enabled && serviceComponent.getPackageName().equals(context.getPackageName())) { + } else if (enabled) { // When # of tuners is changed or the tuner input service is switching from/to using // network tuners or the device just boots. TunerInputInfoUtils.updateTunerInputInfo(context); @@ -313,50 +236,62 @@ public class TunerInputController { /** * Discovers a network tuner. If the network connection is down, it won't repeatedly checking. */ - public void executeNetworkTunerDiscoveryAsyncTask(final Context context) { - executeNetworkTunerDiscoveryAsyncTask(context, 0, 0); + public static void executeNetworkTunerDiscoveryAsyncTask(final Context context) { + boolean runningInMainProcess = + TvApplication.getSingletons(context).isRunningInMainProcess(); + SoftPreconditions.checkState(runningInMainProcess); + if (!runningInMainProcess) { + return; + } + executeNetworkTunerDiscoveryAsyncTask(context, 0); } /** * Discovers a network tuner. * * @param context {@link Context} - * @param repeatedDurationMs The time length to wait to repeatedly check network status to start + * @param repeatedDurationMs the time length to wait to repeatedly check network status to start * finding network tuner when the network connection is not available. {@code 0} to disable * repeatedly checking. - * @param deviceIp The previous discovered device IP, 0 if none. */ - private void executeNetworkTunerDiscoveryAsyncTask( - final Context context, final long repeatedDurationMs, final int deviceIp) { - if (!TunerFeatures.NETWORK_TUNER.isEnabled(context)) { + private static void executeNetworkTunerDiscoveryAsyncTask( + final Context context, final long repeatedDurationMs) { + if (!Features.NETWORK_TUNER.isEnabled(context)) { return; } - final Intent networkCheckingIntent = new Intent(context, IntentReceiver.class); - networkCheckingIntent.setAction(CHECKING_NETWORK_TUNER_STATUS); - if (!isNetworkConnected(context) && repeatedDurationMs > 0) { - sendCheckingAlarm(context, networkCheckingIntent, repeatedDurationMs); - } else { - new AsyncTask<Void, Void, Boolean>() { - @Override - protected Boolean doInBackground(Void... params) { - Boolean result = null; + new AsyncTask<Void, Void, Boolean>() { + @Override + protected Boolean doInBackground(Void... params) { + if (isNetworkConnected(context)) { // Implement and execute network tuner discovery AsyncTask here. - return result; + } else if (repeatedDurationMs > 0) { + AlarmManager alarmManager = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + Intent networkCheckingIntent = new Intent(context, IntentReceiver.class); + networkCheckingIntent.setAction(CHECKING_NETWORK_CONNECTION); + networkCheckingIntent.putExtra(EXTRA_CHECKING_DURATION, repeatedDurationMs); + PendingIntent alarmIntent = + PendingIntent.getBroadcast( + context, + 0, + networkCheckingIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + repeatedDurationMs, + alarmIntent); } + return null; + } - @Override - protected void onPostExecute(Boolean foundNetworkTuner) { - if (foundNetworkTuner == null) { - return; - } - sendCheckingAlarm( - context, - networkCheckingIntent, - foundNetworkTuner ? INITIAL_CHECKING_DURATION_MS : repeatedDurationMs); - onNetworkTunerChanged(context, foundNetworkTuner); + @Override + protected void onPostExecute(Boolean result) { + if (result == null) { + return; } - }.execute(); - } + onNetworkTunerChanged(context, result); + } + }.execute(); } private static boolean isNetworkConnected(Context context) { @@ -366,119 +301,33 @@ public class TunerInputController { return networkInfo != null && networkInfo.isConnected(); } - private static void sendCheckingAlarm(Context context, Intent intent, long delayMs) { - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - intent.putExtra(EXTRA_CHECKING_DURATION, delayMs); - PendingIntent alarmIntent = - PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); - alarmManager.set( - AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + delayMs, - alarmIntent); - } - - private static boolean isTunerPackageInstalled( - Context context, ComponentName serviceComponent) { - try { - context.getPackageManager().getPackageInfo(serviceComponent.getPackageName(), 0); - return true; - } catch (NameNotFoundException e) { - return false; - } - } - - private void sendNotificationToInstallPackage(Context context, ComponentName serviceComponent) { - if (!BuildConfig.ENG) { - return; - } - String applicationName = mTunerApplicationNames.get(serviceComponent); - if (applicationName == null) { - applicationName = context.getString(R.string.tuner_install_default_application_name); - } - String contentTitle = - context.getString( - R.string.tuner_install_notification_content_title, applicationName); - String contentText = mNotificationMessages.get(serviceComponent); - if (contentText == null) { - contentText = context.getString(R.string.tuner_install_notification_content_text); - } - Bitmap largeIcon = mNotificationLargeIcons.get(serviceComponent); - if (largeIcon == null) { - // TODO: Make a better default image. - largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_store); - } - NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - if (notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null) { - createNotificationChannel(context, notificationManager); - } - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setData( - Uri.parse( - String.format( - PLAY_STORE_LINK_TEMPLATE, serviceComponent.getPackageName()))); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - Notification.Builder builder = new Notification.Builder(context, NOTIFICATION_CHANNEL_ID); - builder.setAutoCancel(true) - .setSmallIcon(R.drawable.ic_launcher_s) - .setLargeIcon(largeIcon) - .setContentTitle(contentTitle) - .setContentText(contentText) - .setCategory(Notification.CATEGORY_RECOMMENDATION) - .setContentIntent(PendingIntent.getActivity(context, 0, intent, 0)); - notificationManager.notify(serviceComponent.getPackageName(), 0, builder.build()); - } - - private static void cancelNotificationToInstallPackage( - Context context, ComponentName serviceComponent) { - NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancel(serviceComponent.getPackageName(), 0); - } - - private static void createNotificationChannel( - Context context, NotificationManager notificationManager) { - notificationManager.createNotificationChannel( - new NotificationChannel( - NOTIFICATION_CHANNEL_ID, - context.getResources() - .getString(R.string.ut_setup_notification_channel_name), - NotificationManager.IMPORTANCE_HIGH)); - } - public static class IntentReceiver extends BroadcastReceiver { + private final CheckDvbDeviceHandler mHandler = new CheckDvbDeviceHandler(); @Override public void onReceive(Context context, Intent intent) { if (DEBUG) Log.d(TAG, "Broadcast intent received:" + intent); - Starter.start(context); - TunerInputController tunerInputController = - TvSingletons.getSingletons(context).getTunerInputController(); - if (!TunerFeatures.TUNER.isEnabled(context)) { - tunerInputController.handleTunerStatusChanged( - context, false, Collections.emptySet(), null); + TvApplication.setCurrentRunningProcess(context, true); + if (!Features.TUNER.isEnabled(context)) { + enableTunerTvInputService(context, false, false, null); return; } switch (intent.getAction()) { case Intent.ACTION_BOOT_COMPLETED: - tunerInputController.executeNetworkTunerDiscoveryAsyncTask( - context, INITIAL_CHECKING_DURATION_MS, 0); - // fall through + executeNetworkTunerDiscoveryAsyncTask(context, INITIAL_CHECKING_DURATION_MS); case TvApplication.ACTION_APPLICATION_FIRST_LAUNCHED: case UsbManager.ACTION_USB_DEVICE_ATTACHED: case UsbManager.ACTION_USB_DEVICE_DETACHED: - tunerInputController.onCheckingUsbTunerStatus(context, intent.getAction()); + onCheckingUsbTunerStatus(context, intent.getAction(), mHandler); break; - case CHECKING_NETWORK_TUNER_STATUS: + case CHECKING_NETWORK_CONNECTION: long repeatedDurationMs = intent.getLongExtra( EXTRA_CHECKING_DURATION, INITIAL_CHECKING_DURATION_MS); - tunerInputController.executeNetworkTunerDiscoveryAsyncTask( + executeNetworkTunerDiscoveryAsyncTask( context, - Math.min(repeatedDurationMs * 2, MAXIMUM_CHECKING_DURATION_MS), - intent.getIntExtra(EXTRA_DEVICE_IP, 0)); + Math.min(repeatedDurationMs * 2, MAXIMUM_CHECKING_DURATION_MS)); break; - default: // fall out } } } @@ -500,7 +349,7 @@ public class TunerInputController { this.minSecurityLevel = minSecurityLevel; } - private boolean equalsTo(UsbDevice device) { + private boolean equals(UsbDevice device) { return device.getVendorId() == vendorId && device.getProductId() == productId; } @@ -523,13 +372,10 @@ public class TunerInputController { } private static class CheckDvbDeviceHandler extends Handler { - - private final TunerInputController mTunerInputController; private DvbDeviceAccessor mDvbDeviceAccessor; - CheckDvbDeviceHandler(TunerInputController tunerInputController) { + CheckDvbDeviceHandler() { super(Looper.getMainLooper()); - this.mTunerInputController = tunerInputController; } @Override @@ -541,15 +387,9 @@ public class TunerInputController { mDvbDeviceAccessor = new DvbDeviceAccessor(context); } boolean enabled = mDvbDeviceAccessor.isDvbDeviceAvailable(); - mTunerInputController.handleTunerStatusChanged( - context, - false, - enabled - ? mTunerInputController.getConnectedUsbTuners(context) - : Collections.emptySet(), - TunerHal.TUNER_TYPE_USB); + enableTunerTvInputService( + context, enabled, false, enabled ? TunerHal.TUNER_TYPE_USB : null); break; - default: // fall out } } } diff --git a/src/com/android/tv/tuner/TunerPreferenceProvider.java b/src/com/android/tv/tuner/TunerPreferenceProvider.java new file mode 100644 index 00000000..425c30ac --- /dev/null +++ b/src/com/android/tv/tuner/TunerPreferenceProvider.java @@ -0,0 +1,214 @@ +/* + * 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.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +/** + * A content provider for storing the preferences. It's used across TV app and USB tuner TV input. + */ +public class TunerPreferenceProvider extends ContentProvider { + /** The authority of the provider */ + public static final String AUTHORITY = "com.android.tv.tuner.preferences"; + + private static final String PATH_PREFERENCES = "preferences"; + + private static final int DATABASE_VERSION = 1; + private static final String DATABASE_NAME = "usbtuner_preferences.db"; + private static final String PREFERENCES_TABLE = "preferences"; + + private static final int MATCH_PREFERENCE = 1; + private static final int MATCH_PREFERENCE_KEY = 2; + + private static final UriMatcher sUriMatcher; + + private DatabaseOpenHelper mDatabaseOpenHelper; + + static { + sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + sUriMatcher.addURI(AUTHORITY, "preferences", MATCH_PREFERENCE); + sUriMatcher.addURI(AUTHORITY, "preferences/*", MATCH_PREFERENCE_KEY); + } + + /** + * Builds a Uri that points to a specific preference. + * + * @param key a key of the preference to point to + */ + public static Uri buildPreferenceUri(String key) { + return Preferences.CONTENT_URI.buildUpon().appendPath(key).build(); + } + + /** Columns definitions for the preferences table. */ + public interface Preferences { + + /** The content:// style for the preferences table. */ + Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/" + PATH_PREFERENCES); + + /** The MIME type of a directory of preferences. */ + String CONTENT_TYPE = "vnd.android.cursor.dir/preferences"; + + /** The MIME type of a single preference. */ + String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/preferences"; + + /** + * The ID of this preference. + * + * <p>This is auto-incremented. + * + * <p>Type: INTEGER + */ + String _ID = "_id"; + + /** + * The key of this preference. + * + * <p>Should be unique. + * + * <p>Type: TEXT + */ + String COLUMN_KEY = "key"; + + /** + * The value of this preference. + * + * <p>Type: TEXT + */ + String COLUMN_VALUE = "value"; + } + + private static class DatabaseOpenHelper extends SQLiteOpenHelper { + public DatabaseOpenHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL( + "CREATE TABLE " + + PREFERENCES_TABLE + + " (" + + Preferences._ID + + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Preferences.COLUMN_KEY + + " TEXT NOT NULL," + + Preferences.COLUMN_VALUE + + " TEXT);"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // No-op + } + } + + @Override + public boolean onCreate() { + mDatabaseOpenHelper = new DatabaseOpenHelper(getContext()); + return true; + } + + @Override + public Cursor query( + Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String sortOrder) { + int match = sUriMatcher.match(uri); + if (match != MATCH_PREFERENCE && match != MATCH_PREFERENCE_KEY) { + throw new UnsupportedOperationException(); + } + SQLiteDatabase db = mDatabaseOpenHelper.getReadableDatabase(); + Cursor cursor = + db.query( + PREFERENCES_TABLE, + projection, + selection, + selectionArgs, + null, + null, + sortOrder); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + return cursor; + } + + @Override + public String getType(Uri uri) { + switch (sUriMatcher.match(uri)) { + case MATCH_PREFERENCE: + return Preferences.CONTENT_TYPE; + case MATCH_PREFERENCE_KEY: + return Preferences.CONTENT_ITEM_TYPE; + } + throw new IllegalArgumentException("Unknown URI " + uri); + } + + /** + * Inserts a preference row into the preference table. + * + * <p>If a key is already exists in the table, it removes the old row and inserts a new row. + * + * @param uri the URL of the table to insert into + * @param values the initial values for the newly inserted row + * @return the URL of the newly created row + */ + @Override + public Uri insert(Uri uri, ContentValues values) { + if (sUriMatcher.match(uri) != MATCH_PREFERENCE) { + throw new UnsupportedOperationException(); + } + return insertRow(uri, values); + } + + private Uri insertRow(Uri uri, ContentValues values) { + SQLiteDatabase db = mDatabaseOpenHelper.getWritableDatabase(); + + // Remove the old row. + db.delete( + PREFERENCES_TABLE, + Preferences.COLUMN_KEY + " like ?", + new String[] {values.getAsString(Preferences.COLUMN_KEY)}); + + long rowId = db.insert(PREFERENCES_TABLE, null, values); + if (rowId > 0) { + Uri rowUri = buildPreferenceUri(values.getAsString(Preferences.COLUMN_KEY)); + getContext().getContentResolver().notifyChange(rowUri, null); + return rowUri; + } + + throw new SQLiteException("Failed to insert row into " + uri); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } +} diff --git a/src/com/android/tv/tuner/TunerPreferences.java b/src/com/android/tv/tuner/TunerPreferences.java new file mode 100644 index 00000000..62a4ce99 --- /dev/null +++ b/src/com/android/tv/tuner/TunerPreferences.java @@ -0,0 +1,428 @@ +/* + * 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.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.database.Cursor; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.support.annotation.GuardedBy; +import android.support.annotation.IntDef; +import android.support.annotation.MainThread; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.TunerPreferenceProvider.Preferences; +import com.android.tv.tuner.util.TisConfiguration; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** A helper class for the USB tuner preferences. */ +public class TunerPreferences { + 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_LAST_POSTAL_CODE = "last_postal_code"; + private static final String PREFS_KEY_SCAN_DONE = "scan_done"; + private static final String PREFS_KEY_LAUNCH_SETUP = "launch_setup"; + private static final String PREFS_KEY_STORE_TS_STREAM = "store_ts_stream"; + private static final String PREFS_KEY_TRICKPLAY_SETTING = "trickplay_setting"; + private static final String PREFS_KEY_TRICKPLAY_EXPIRED_MS = "trickplay_expired_ms"; + + private static final String SHARED_PREFS_NAME = "com.android.tv.tuner.preferences"; + + public static final int CHANNEL_DATA_VERSION_NOT_SET = -1; + + @IntDef({TRICKPLAY_SETTING_NOT_SET, TRICKPLAY_SETTING_DISABLED, TRICKPLAY_SETTING_ENABLED}) + @Retention(RetentionPolicy.SOURCE) + public @interface TrickplaySetting {} + + /** Trickplay setting is not changed by a user. Trickplay will be enabled in this case. */ + public static final int TRICKPLAY_SETTING_NOT_SET = -1; + + /** Trickplay setting is disabled. */ + public static final int TRICKPLAY_SETTING_DISABLED = 0; + + /** Trickplay setting is enabled. */ + public static final int TRICKPLAY_SETTING_ENABLED = 1; + + @GuardedBy("TunerPreferences.class") + private static final Bundle sPreferenceValues = new Bundle(); + + private static LoadPreferencesTask sLoadPreferencesTask; + private static ContentObserver sContentObserver; + private static TunerPreferencesChangedListener sPreferencesChangedListener = null; + + private static boolean sInitialized; + + /** Listeners for TunerPreferences change. */ + public interface TunerPreferencesChangedListener { + void onTunerPreferencesChanged(); + } + + /** Initializes the USB tuner preferences. */ + @MainThread + public static void initialize(final Context context) { + if (sInitialized) { + return; + } + sInitialized = true; + if (useContentProvider(context)) { + loadPreferences(context); + sContentObserver = + new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + loadPreferences(context); + } + }; + context.getContentResolver() + .registerContentObserver( + TunerPreferenceProvider.Preferences.CONTENT_URI, + true, + sContentObserver); + } else { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + getSharedPreferences(context); + return null; + } + }.execute(); + } + } + + /** Releases the resources. */ + public static synchronized void release(Context context) { + if (useContentProvider(context) && sContentObserver != null) { + context.getContentResolver().unregisterContentObserver(sContentObserver); + } + setTunerPreferencesChangedListener(null); + } + + /** Sets the listener for TunerPreferences change. */ + public static void setTunerPreferencesChangedListener( + TunerPreferencesChangedListener listener) { + sPreferencesChangedListener = listener; + } + + /** + * Loads the preferences from database. + * + * <p>This preferences is used across processes, so the preferences should be loaded again when + * the databases changes. + */ + @MainThread + public static void loadPreferences(Context context) { + if (sLoadPreferencesTask != null + && sLoadPreferencesTask.getStatus() != AsyncTask.Status.FINISHED) { + sLoadPreferencesTask.cancel(true); + } + sLoadPreferencesTask = new LoadPreferencesTask(context); + sLoadPreferencesTask.execute(); + } + + private static boolean useContentProvider(Context context) { + // If TIS is a part of LC, it should use ContentProvider to resolve multiple process access. + return TisConfiguration.isPackagedWithLiveChannels(context); + } + + public static synchronized int getChannelDataVersion(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getInt( + PREFS_KEY_CHANNEL_DATA_VERSION, CHANNEL_DATA_VERSION_NOT_SET); + } else { + return getSharedPreferences(context) + .getInt( + TunerPreferences.PREFS_KEY_CHANNEL_DATA_VERSION, + CHANNEL_DATA_VERSION_NOT_SET); + } + } + + public static synchronized void setChannelDataVersion(Context context, int version) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_CHANNEL_DATA_VERSION, version); + } else { + getSharedPreferences(context) + .edit() + .putInt(TunerPreferences.PREFS_KEY_CHANNEL_DATA_VERSION, version) + .apply(); + } + } + + public static synchronized int getScannedChannelCount(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getInt(PREFS_KEY_SCANNED_CHANNEL_COUNT); + } else { + return getSharedPreferences(context) + .getInt(TunerPreferences.PREFS_KEY_SCANNED_CHANNEL_COUNT, 0); + } + } + + public static synchronized void setScannedChannelCount(Context context, int channelCount) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_SCANNED_CHANNEL_COUNT, channelCount); + } else { + getSharedPreferences(context) + .edit() + .putInt(TunerPreferences.PREFS_KEY_SCANNED_CHANNEL_COUNT, channelCount) + .apply(); + } + } + + public static synchronized String getLastPostalCode(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getString(PREFS_KEY_LAST_POSTAL_CODE); + } else { + return getSharedPreferences(context).getString(PREFS_KEY_LAST_POSTAL_CODE, null); + } + } + + public static synchronized void setLastPostalCode(Context context, String postalCode) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_LAST_POSTAL_CODE, postalCode); + } else { + getSharedPreferences(context) + .edit() + .putString(PREFS_KEY_LAST_POSTAL_CODE, postalCode) + .apply(); + } + } + + public static synchronized boolean isScanDone(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getBoolean(PREFS_KEY_SCAN_DONE); + } else { + return getSharedPreferences(context) + .getBoolean(TunerPreferences.PREFS_KEY_SCAN_DONE, false); + } + } + + public static synchronized void setScanDone(Context context) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_SCAN_DONE, true); + } else { + getSharedPreferences(context) + .edit() + .putBoolean(TunerPreferences.PREFS_KEY_SCAN_DONE, true) + .apply(); + } + } + + public static synchronized boolean shouldShowSetupActivity(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getBoolean(PREFS_KEY_LAUNCH_SETUP); + } else { + return getSharedPreferences(context) + .getBoolean(TunerPreferences.PREFS_KEY_LAUNCH_SETUP, false); + } + } + + public static synchronized void setShouldShowSetupActivity(Context context, boolean need) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_LAUNCH_SETUP, need); + } else { + getSharedPreferences(context) + .edit() + .putBoolean(TunerPreferences.PREFS_KEY_LAUNCH_SETUP, need) + .apply(); + } + } + + public static synchronized long getTrickplayExpiredMs(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getLong(PREFS_KEY_TRICKPLAY_EXPIRED_MS, 0); + } else { + return getSharedPreferences(context) + .getLong(TunerPreferences.PREFS_KEY_TRICKPLAY_EXPIRED_MS, 0); + } + } + + public static synchronized void setTrickplayExpiredMs(Context context, long timeMs) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_TRICKPLAY_EXPIRED_MS, timeMs); + } else { + getSharedPreferences(context) + .edit() + .putLong(TunerPreferences.PREFS_KEY_TRICKPLAY_EXPIRED_MS, timeMs) + .apply(); + } + } + + public static synchronized @TrickplaySetting int getTrickplaySetting(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getInt(PREFS_KEY_TRICKPLAY_SETTING, TRICKPLAY_SETTING_NOT_SET); + } else { + return getSharedPreferences(context) + .getInt( + TunerPreferences.PREFS_KEY_TRICKPLAY_SETTING, + TRICKPLAY_SETTING_NOT_SET); + } + } + + public static synchronized void setTrickplaySetting( + Context context, @TrickplaySetting int trickplaySetting) { + SoftPreconditions.checkState(sInitialized); + SoftPreconditions.checkArgument(trickplaySetting != TRICKPLAY_SETTING_NOT_SET); + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_TRICKPLAY_SETTING, trickplaySetting); + } else { + getSharedPreferences(context) + .edit() + .putInt(TunerPreferences.PREFS_KEY_TRICKPLAY_SETTING, trickplaySetting) + .apply(); + } + } + + public static synchronized boolean getStoreTsStream(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getBoolean(PREFS_KEY_STORE_TS_STREAM, false); + } else { + return getSharedPreferences(context) + .getBoolean(TunerPreferences.PREFS_KEY_STORE_TS_STREAM, false); + } + } + + public static synchronized void setStoreTsStream(Context context, boolean shouldStore) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_STORE_TS_STREAM, shouldStore); + } else { + getSharedPreferences(context) + .edit() + .putBoolean(TunerPreferences.PREFS_KEY_STORE_TS_STREAM, shouldStore) + .apply(); + } + } + + private static SharedPreferences getSharedPreferences(Context context) { + return context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE); + } + + private static synchronized void setPreference(Context context, String key, String value) { + sPreferenceValues.putString(key, value); + savePreference(context, key, value); + } + + private static synchronized void setPreference(Context context, String key, int value) { + sPreferenceValues.putInt(key, value); + savePreference(context, key, Integer.toString(value)); + } + + private static synchronized void setPreference(Context context, String key, long value) { + sPreferenceValues.putLong(key, value); + savePreference(context, key, Long.toString(value)); + } + + private static synchronized void setPreference(Context context, String key, boolean value) { + sPreferenceValues.putBoolean(key, value); + savePreference(context, key, Boolean.toString(value)); + } + + private static void savePreference( + final Context context, final String key, final String value) { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + ContentResolver resolver = context.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(Preferences.COLUMN_KEY, key); + values.put(Preferences.COLUMN_VALUE, value); + try { + resolver.insert(Preferences.CONTENT_URI, values); + } catch (Exception e) { + SoftPreconditions.warn( + TAG, "setPreference", "Error writing preference values", e); + } + return null; + } + }.execute(); + } + + private static class LoadPreferencesTask extends AsyncTask<Void, Void, Bundle> { + private final Context mContext; + + private LoadPreferencesTask(Context context) { + mContext = context; + } + + @Override + protected Bundle doInBackground(Void... params) { + Bundle bundle = new Bundle(); + ContentResolver resolver = mContext.getContentResolver(); + String[] projection = new String[] {Preferences.COLUMN_KEY, Preferences.COLUMN_VALUE}; + try (Cursor cursor = + resolver.query(Preferences.CONTENT_URI, projection, null, null, null)) { + if (cursor != null) { + while (!isCancelled() && cursor.moveToNext()) { + String key = cursor.getString(0); + String value = cursor.getString(1); + switch (key) { + case PREFS_KEY_TRICKPLAY_EXPIRED_MS: + bundle.putLong(key, Long.parseLong(value)); + break; + case PREFS_KEY_CHANNEL_DATA_VERSION: + case PREFS_KEY_SCANNED_CHANNEL_COUNT: + case PREFS_KEY_TRICKPLAY_SETTING: + try { + bundle.putInt(key, Integer.parseInt(value)); + } catch (NumberFormatException e) { + // Does nothing. + } + break; + case PREFS_KEY_SCAN_DONE: + case PREFS_KEY_LAUNCH_SETUP: + case PREFS_KEY_STORE_TS_STREAM: + bundle.putBoolean(key, Boolean.parseBoolean(value)); + break; + case PREFS_KEY_LAST_POSTAL_CODE: + bundle.putString(key, value); + break; + } + } + } + } catch (Exception e) { + SoftPreconditions.warn(TAG, "getPreference", "Error querying preference values", e); + return null; + } + return bundle; + } + + @Override + protected void onPostExecute(Bundle bundle) { + synchronized (TunerPreferences.class) { + if (bundle != null) { + sPreferenceValues.putAll(bundle); + } + } + if (sPreferencesChangedListener != null) { + sPreferencesChangedListener.onTunerPreferencesChanged(); + } + } + } +} diff --git a/src/com/android/tv/tuner/cc/CaptionLayout.java b/src/com/android/tv/tuner/cc/CaptionLayout.java new file mode 100644 index 00000000..eb9ad463 --- /dev/null +++ b/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/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java b/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java new file mode 100644 index 00000000..bcb8e1c0 --- /dev/null +++ b/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java @@ -0,0 +1,340 @@ +/* + * 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; +import java.util.concurrent.TimeUnit; + +/** 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 = TimeUnit.MILLISECONDS.toMillis(100); + + // 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/src/com/android/tv/tuner/cc/CaptionWindowLayout.java b/src/com/android/tv/tuner/cc/CaptionWindowLayout.java new file mode 100644 index 00000000..e9371f94 --- /dev/null +++ b/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.layout.ScaledLayout; +import com.google.android.exoplayer.text.CaptionStyleCompat; +import com.google.android.exoplayer.text.SubtitleView; +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/src/com/android/tv/tuner/cc/Cea708Parser.java b/src/com/android/tv/tuner/cc/Cea708Parser.java new file mode 100644 index 00000000..4e080276 --- /dev/null +++ b/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/src/com/android/tv/tuner/data/Cea708Data.java b/src/com/android/tv/tuner/data/Cea708Data.java new file mode 100644 index 00000000..73a90181 --- /dev/null +++ b/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/src/com/android/tv/tuner/data/PsiData.java b/src/com/android/tv/tuner/data/PsiData.java new file mode 100644 index 00000000..9b7c2e2c --- /dev/null +++ b/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/src/com/android/tv/tuner/data/PsipData.java b/src/com/android/tv/tuner/data/PsipData.java new file mode 100644 index 00000000..6459004c --- /dev/null +++ b/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.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 com.android.tv.util.StringUtils; +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/src/com/android/tv/tuner/data/TunerChannel.java b/src/com/android/tv/tuner/data/TunerChannel.java new file mode 100644 index 00000000..52356c2e --- /dev/null +++ b/src/com/android/tv/tuner/data/TunerChannel.java @@ -0,0 +1,517 @@ +/* + * 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.util.Log; +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.android.tv.util.StringUtils; +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.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.MPEG1: + case Channel.MPEG2: + case Channel.H263: + case Channel.H264: + case Channel.H265: + mProto.videoPid = pmt.getEsPid(); + mProto.videoStreamType = pmt.getStreamType(); + break; + + // MPEG ES stream audio types + case Channel.MPEG1AUDIO: + case Channel.MPEG2AUDIO: + case Channel.MPEG2AACAUDIO: + case Channel.MPEG4LATMAACAUDIO: + case Channel.A52AC3AUDIO: + case Channel.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; + } + } + 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.TYPE_TUNER); + } + + /** Initialize tuner channel with program number and PMT items. */ + public TunerChannel(int programNumber, List<PsiData.PmtItem> pmtItems) { + this(null, programNumber, pmtItems, Channel.TYPE_TUNER); + } + + /** Initialize tuner channel with SDT items and PMT items. */ + public TunerChannel(PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) { + this(0, Channel.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.TYPE_FILE); + } + + public static TunerChannel forDvbFile( + PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) { + return new TunerChannel(0, Channel.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.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; + } + + 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.TYPE_FILE: + return String.format( + "{%d-%d %s} Filepath: %s, ProgramNumber %d", + mProto.virtualMajor, + mProto.virtualMinor, + mProto.shortName, + mProto.filepath, + mProto.programNumber); + // case Channel.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; + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java new file mode 100644 index 00000000..1f48c45b --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java b/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java new file mode 100644 index 00000000..b5369d69 --- /dev/null +++ b/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.TimestampAdjuster; +import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; +import com.google.android.exoplayer2.extractor.ts.TsExtractor; + +/** + * 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( + new TimestampAdjuster(0), + new DefaultTsPayloadReaderFactory( + DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES), + false) + }; + return extractors; + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java new file mode 100644 index 00000000..df520900 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java @@ -0,0 +1,610 @@ +/* + * 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.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.LinkedList; +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 LinkedList<>(); + private OnCompletionListener mOnCompletionListener; + private Handler mOnCompletionListenerHandler; + private IOException mError; + + public ExoPlayerSampleExtractor( + Uri uri, + final DataSource source, + BufferManager bufferManager, + PlaybackBufferListener bufferListener, + boolean isRecording) { + // 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 = new HandlerThread("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(), + // Do not create a handler if we not on a looper. e.g. test. + Looper.myLooper() != null ? new Handler() : null, + 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(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); + } 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( + 0, + new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + 0); + mMediaPeriod.prepare(this); + 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, mSampleHolder, 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; + } + return false; + } + + private int fetchSample( + int track, SampleHolder sample, ConditionVariable conditionVariable) { + FormatHolder dummyFormatHolder = new FormatHolder(); + mDecoderInputBuffer.clear(); + int ret = mStreams[track].readData(dummyFormatHolder, mDecoderInputBuffer); + 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; + } + 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/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java new file mode 100644 index 00000000..e7224422 --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java b/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java new file mode 100644 index 00000000..a49cbfaf --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java b/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java new file mode 100644 index 00000000..c7f5b333 --- /dev/null +++ b/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.Features; +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, + !Features.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/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java new file mode 100644 index 00000000..593b576e --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java b/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java new file mode 100644 index 00000000..3b5d1011 --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java new file mode 100644 index 00000000..c4400b47 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java @@ -0,0 +1,111 @@ +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.common.feature.CommonFeatures; +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 = CommonFeatures.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/src/com/android/tv/tuner/exoplayer/SampleExtractor.java b/src/com/android/tv/tuner/exoplayer/SampleExtractor.java new file mode 100644 index 00000000..256aea92 --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java b/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java new file mode 100644 index 00000000..13eabc3a --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java b/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java new file mode 100644 index 00000000..fa489883 --- /dev/null +++ b/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 mime 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/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java b/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java new file mode 100644 index 00000000..28389017 --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java b/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java new file mode 100644 index 00000000..7446c923 --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java b/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java new file mode 100644 index 00000000..80f91ebd --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java new file mode 100644 index 00000000..ae18e05d --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java @@ -0,0 +1,743 @@ +/* + * 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.exoplayer.ffmpeg.FfmpegDecoderClient; +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; + mSoftwareDecoderAvailable = hasSoftwareAudioDecoder && FfmpegDecoderClient.isAvailable(); + } + + @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); + } else if (mSoftwareDecoderAvailable + && (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mimeType) + || MimeTypes.AUDIO_AC3.equalsIgnoreCase(mimeType) && !mAc3Passthrough)) { + releaseDecoder(); + mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT); + mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE); + mAudioDecoder = FfmpegDecoderClient.getInstance(); + mDecodingMime = mimeType; + mFormat = convertMediaFormatToRaw(formatHolder.format); + } 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/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java new file mode 100644 index 00000000..b382545f --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java new file mode 100644 index 00000000..206e2098 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java @@ -0,0 +1,682 @@ +/* + * 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.tuner.exoplayer.SampleExtractor; +import com.android.tv.util.Utils; +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; + +/** + * 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 volatile int mSpeedCheckCount; + + 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(), + Utils.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 > MAXIMUM_SPEED_CHECK_COUNT) { + return false; + } + mSpeedCheckCount++; + 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 > 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/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java b/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java new file mode 100644 index 00000000..2a58ffcf --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java b/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java new file mode 100644 index 00000000..ebf00f59 --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java new file mode 100644 index 00000000..023d3295 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java @@ -0,0 +1,428 @@ +/* + * 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 + */ + 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. + */ + 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 (nextChunk != null) { + if (mChunk == null || 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/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java new file mode 100644 index 00000000..06fd6558 --- /dev/null +++ b/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 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/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java b/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java new file mode 100644 index 00000000..b89a14db --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java new file mode 100644 index 00000000..e208f2c2 --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java b/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java new file mode 100644 index 00000000..4c6260bf --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java b/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java new file mode 100644 index 00000000..b22b8af1 --- /dev/null +++ b/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/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderClient.java b/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderClient.java new file mode 100644 index 00000000..421192f1 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderClient.java @@ -0,0 +1,247 @@ +/* + * 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.ffmpeg; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.os.RemoteException; +import android.support.annotation.MainThread; +import android.support.annotation.VisibleForTesting; +import android.support.annotation.WorkerThread; +import com.android.tv.Features; +import com.android.tv.tuner.exoplayer.audio.AudioDecoder; +import com.google.android.exoplayer.SampleHolder; +import java.nio.ByteBuffer; + +/** + * The class connects {@link FfmpegDecoderService} to decode audio samples. In order to sandbox + * ffmpeg based decoder, {@link FfmpegDecoderService} is an isolated process without any permission + * and connected by binder. + */ +public class FfmpegDecoderClient extends AudioDecoder { + private static FfmpegDecoderClient sInstance; + + private IFfmpegDecoder mService; + private Boolean mIsAvailable; + + private static final String FFMPEG_DECODER_SERVICE_FILTER = + "com.android.tv.tuner.exoplayer.ffmpeg.IFfmpegDecoder"; + private static final long FFMPEG_SERVICE_CONNECT_TIMEOUT_MS = 500; + + private final ServiceConnection mConnection = + new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, IBinder service) { + mService = IFfmpegDecoder.Stub.asInterface(service); + synchronized (FfmpegDecoderClient.this) { + try { + mIsAvailable = mService.isAvailable(); + } catch (RemoteException e) { + } + FfmpegDecoderClient.this.notify(); + } + } + + @Override + public void onServiceDisconnected(ComponentName className) { + synchronized (FfmpegDecoderClient.this) { + sInstance.releaseLocked(); + mIsAvailable = false; + mService = null; + } + } + }; + + /** + * Connects to the decoder service for future uses. + * + * @param context + * @return {@code true} when decoder service is connected. + */ + @MainThread + public static synchronized boolean connect(Context context) { + if (Features.AC3_SOFTWARE_DECODE.isEnabled(context)) { + if (sInstance == null) { + sInstance = new FfmpegDecoderClient(); + Intent intent = + new Intent(FFMPEG_DECODER_SERVICE_FILTER) + .setComponent( + new ComponentName(context, FfmpegDecoderService.class)); + if (context.bindService(intent, sInstance.mConnection, Context.BIND_AUTO_CREATE)) { + return true; + } else { + sInstance = null; + } + } + } + return false; + } + + /** + * Disconnects from the decoder service and release resources. + * + * @param context + */ + @MainThread + public static synchronized void disconnect(Context context) { + if (sInstance != null) { + synchronized (sInstance) { + sInstance.releaseLocked(); + if (sInstance.mIsAvailable != null && sInstance.mIsAvailable) { + context.unbindService(sInstance.mConnection); + } + sInstance.mIsAvailable = false; + sInstance.mService = null; + } + sInstance = null; + } + } + + /** + * Returns whether service is available or not. Before using client, this should be used to + * check availability. + */ + @WorkerThread + public static synchronized boolean isAvailable() { + if (sInstance != null) { + return sInstance.available(); + } + return false; + } + + /** Returns an client instance. */ + public static synchronized FfmpegDecoderClient getInstance() { + if (sInstance != null) { + sInstance.createDecoder(); + } + return sInstance; + } + + private FfmpegDecoderClient() {} + + private synchronized boolean available() { + if (mIsAvailable == null) { + try { + this.wait(FFMPEG_SERVICE_CONNECT_TIMEOUT_MS); + } catch (InterruptedException e) { + } + } + return mIsAvailable != null && mIsAvailable == true; + } + + private synchronized void createDecoder() { + if (mIsAvailable == null || mIsAvailable == false) { + return; + } + try { + mService.create(); + } catch (RemoteException e) { + } + } + + private void releaseLocked() { + if (mIsAvailable == null || mIsAvailable == false) { + return; + } + try { + mService.release(); + } catch (RemoteException e) { + } + } + + @Override + public synchronized void release() { + releaseLocked(); + } + + @Override + public synchronized void decode(SampleHolder sampleHolder) { + if (mIsAvailable == null || mIsAvailable == false) { + return; + } + byte[] sampleBytes = new byte[sampleHolder.data.limit()]; + sampleHolder.data.get(sampleBytes, 0, sampleBytes.length); + try { + mService.decode(sampleHolder.timeUs, sampleBytes); + } catch (RemoteException e) { + } + } + + @Override + public synchronized void resetDecoderState(String mimeType) { + if (mIsAvailable == null || mIsAvailable == false) { + return; + } + try { + mService.resetDecoderState(mimeType); + } catch (RemoteException e) { + } + } + + @Override + public synchronized ByteBuffer getDecodedSample() { + if (mIsAvailable == null || mIsAvailable == false) { + return null; + } + try { + byte[] outputBytes = mService.getDecodedSample(); + if (outputBytes != null && outputBytes.length > 0) { + return ByteBuffer.wrap(outputBytes); + } + } catch (RemoteException e) { + } + return null; + } + + @Override + public synchronized long getDecodedTimeUs() { + if (mIsAvailable == null || mIsAvailable == false) { + return 0; + } + try { + return mService.getDecodedTimeUs(); + } catch (RemoteException e) { + } + return 0; + } + + @VisibleForTesting + public boolean testSandboxIsolatedProcess() { + // When testing isolated process, we will check the permission in FfmpegDecoderService. + // If the service have any permission, an exception will be thrown. + try { + mService.testSandboxIsolatedProcess(); + } catch (RemoteException e) { + return false; + } + return true; + } + + @VisibleForTesting + public void testSandboxMinijail() { + // When testing minijail, we will call a system call which is blocked by minijail. In that + // case, the FfmpegDecoderService will be disconnected, we can check the connection status + // to make sure if the minijail works or not. + try { + mService.testSandboxMinijail(); + } catch (RemoteException e) { + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderService.java b/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderService.java new file mode 100644 index 00000000..0172d817 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderService.java @@ -0,0 +1,202 @@ +/* + * 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.ffmpeg; + +import android.app.Service; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.AssetFileDescriptor; +import android.os.AsyncTask; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.util.Log; +import com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioDecoder; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** Ffmpeg based audio decoder service. It should be isolatedProcess due to security reason. */ +public class FfmpegDecoderService extends Service { + private static final String TAG = "FfmpegDecoderService"; + private static final boolean DEBUG = false; + + private static final String POLICY_FILE = "whitelist.policy"; + + private static final long MINIJAIL_SETUP_WAIT_TIMEOUT_MS = 5000; + + private static boolean sLibraryLoaded = true; + + static { + try { + System.loadLibrary("minijail_jni"); + } catch (Exception | Error e) { + Log.e(TAG, "Load minijail failed:", e); + sLibraryLoaded = false; + } + } + + private FfmpegDecoder mBinder = new FfmpegDecoder(); + private volatile Object mMinijailSetupMonitor = new Object(); + // @GuardedBy("mMinijailSetupMonitor") + private volatile Boolean mMinijailSetup; + + @Override + public void onCreate() { + if (sLibraryLoaded) { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + synchronized (mMinijailSetupMonitor) { + int pipeFd = getPolicyPipeFd(); + if (pipeFd <= 0) { + Log.e(TAG, "fail to open policy file"); + mMinijailSetup = false; + } else { + nativeSetupMinijail(pipeFd); + mMinijailSetup = true; + if (DEBUG) Log.d(TAG, "Minijail setup successfully"); + } + mMinijailSetupMonitor.notify(); + } + return null; + } + }.execute(); + } else { + synchronized (mMinijailSetupMonitor) { + mMinijailSetup = false; + mMinijailSetupMonitor.notify(); + } + } + super.onCreate(); + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + private int getPolicyPipeFd() { + try { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + final ParcelFileDescriptor.AutoCloseOutputStream outputStream = + new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]); + final AssetFileDescriptor policyFile = getAssets().openFd("whitelist.policy"); + final byte[] buffer = new byte[2048]; + final FileInputStream policyStream = policyFile.createInputStream(); + while (true) { + int bytesRead = policyStream.read(buffer); + if (bytesRead == -1) break; + outputStream.write(buffer, 0, bytesRead); + } + policyStream.close(); + outputStream.close(); + return pipe[0].detachFd(); + } catch (IOException e) { + Log.e(TAG, "Policy file not found:" + e); + } + return -1; + } + + private final class FfmpegDecoder extends IFfmpegDecoder.Stub { + FfmpegAudioDecoder mDecoder; + + @Override + public boolean isAvailable() { + return isMinijailSetupDone() && FfmpegAudioDecoder.isAvailable(); + } + + @Override + public void create() { + mDecoder = new FfmpegAudioDecoder(FfmpegDecoderService.this); + } + + @Override + public void release() { + if (mDecoder != null) { + mDecoder.release(); + mDecoder = null; + } + } + + @Override + public void decode(long timeUs, byte[] sample) { + if (!isMinijailSetupDone()) { + // If minijail is not setup, we don't run decode for better security. + return; + } + mDecoder.decode(timeUs, sample); + } + + @Override + public void resetDecoderState(String mimetype) { + mDecoder.resetDecoderState(mimetype); + } + + @Override + public byte[] getDecodedSample() { + ByteBuffer decodedBuffer = mDecoder.getDecodedSample(); + byte[] ret = new byte[decodedBuffer.limit()]; + decodedBuffer.get(ret, 0, ret.length); + return ret; + } + + @Override + public long getDecodedTimeUs() { + return mDecoder.getDecodedTimeUs(); + } + + private boolean isMinijailSetupDone() { + synchronized (mMinijailSetupMonitor) { + if (DEBUG) Log.d(TAG, "mMinijailSetup in isAvailable(): " + mMinijailSetup); + if (mMinijailSetup == null) { + try { + if (DEBUG) Log.d(TAG, "Wait till Minijail setup is done"); + mMinijailSetupMonitor.wait(MINIJAIL_SETUP_WAIT_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return mMinijailSetup != null && mMinijailSetup; + } + } + + @Override + public void testSandboxIsolatedProcess() { + if (!isMinijailSetupDone()) { + // If minijail is not setup, we return directly to make the test fail. + return; + } + if (FfmpegDecoderService.this.checkSelfPermission("android.permission.INTERNET") + == PackageManager.PERMISSION_GRANTED) { + throw new SecurityException("Shouldn't have the permission of internet"); + } + } + + @Override + public void testSandboxMinijail() { + if (!isMinijailSetupDone()) { + // If minijail is not setup, we return directly to make the test fail. + return; + } + nativeTestMinijail(); + } + } + + private native void nativeSetupMinijail(int policyFd); + + private native void nativeTestMinijail(); +} diff --git a/src/com/android/tv/tuner/exoplayer/ffmpeg/IFfmpegDecoder.aidl b/src/com/android/tv/tuner/exoplayer/ffmpeg/IFfmpegDecoder.aidl new file mode 100644 index 00000000..ed053790 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ffmpeg/IFfmpegDecoder.aidl @@ -0,0 +1,29 @@ +/* + * 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.ffmpeg; + +interface IFfmpegDecoder { + boolean isAvailable(); + void create(); + void release(); + void resetDecoderState(String mimetype); + void decode(long timeUs, in byte[] sample); + byte[] getDecodedSample(); + long getDecodedTimeUs(); + void testSandboxIsolatedProcess(); + void testSandboxMinijail(); +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/layout/ScaledLayout.java b/src/com/android/tv/tuner/layout/ScaledLayout.java new file mode 100644 index 00000000..dd92b641 --- /dev/null +++ b/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/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java b/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java new file mode 100644 index 00000000..75d1c34c --- /dev/null +++ b/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java @@ -0,0 +1,98 @@ +/* + * 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.ui.setup.SetupGuidedStepFragment; +import com.android.tv.common.ui.setup.SetupMultiPaneFragment; +import com.android.tv.tuner.R; +import java.util.List; + +/** 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) { + ((TunerSetupActivity) getActivity()).generateTunerHal(); + super.onCreate(savedInstanceState); + } + + @Override + public void onResume() { + ((TunerSetupActivity) getActivity()).generateTunerHal(); + super.onResume(); + } + + @Override + public void onDestroy() { + ((TunerSetupActivity) getActivity()).clearTunerHal(); + super.onDestroy(); + } + + @Override + protected SetupGuidedStepFragment onCreateContentFragment() { + return new ContentFragment(); + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + + @Override + protected boolean needsDoneButton() { + return false; + } + + 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/src/com/android/tv/tuner/setup/PostalCodeFragment.java b/src/com/android/tv/tuner/setup/PostalCodeFragment.java new file mode 100644 index 00000000..fbf03909 --- /dev/null +++ b/src/com/android/tv/tuner/setup/PostalCodeFragment.java @@ -0,0 +1,179 @@ +/* + * 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.R; +import com.android.tv.common.ui.setup.SetupGuidedStepFragment; +import com.android.tv.common.ui.setup.SetupMultiPaneFragment; +import com.android.tv.tuner.util.PostalCodeUtils; +import com.android.tv.util.LocationUtils; +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"; + 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()).mEditAction.getTitle(); + String region = LocationUtils.getCurrentCountry(getContext()); + if (postalCode != null && PostalCodeUtils.matches(postalCode, region)) { + PostalCodeUtils.setLastPostalCode( + getContext(), postalCode.toString()); + onActionClick(category, actionId); + } 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); + } + } + + public static class ContentFragment extends SetupGuidedStepFragment { + private GuidedAction mEditAction; + private View mEditedActionView; + 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 = getActivity().findViewById(R.id.button_done); + } + 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); + ((TextView) mEditedActionView.findViewById(R.id.guidedactions_item_title)) + .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/src/com/android/tv/tuner/setup/ScanFragment.java b/src/com/android/tv/tuner/setup/ScanFragment.java new file mode 100644 index 00000000..044b0d26 --- /dev/null +++ b/src/com/android/tv/tuner/setup/ScanFragment.java @@ -0,0 +1,542 @@ +/* + * 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"; + + 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; + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreateView"); + View view = super.onCreateView(inflater, container, savedInstanceState); + 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(TunerSetupActivity.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 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 = ((TunerSetupActivity) 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.TYPE_TUNER: + return mScanTsStreamer; + case Channel.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); + } + } + + 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. + TunerSetupActivity.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()) { + onActionClick(ACTION_CATEGORY, mIsCanceled ? ACTION_CANCEL : ACTION_FINISH); + } 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/src/com/android/tv/tuner/setup/ScanResultFragment.java b/src/com/android/tv/tuner/setup/ScanResultFragment.java new file mode 100644 index 00000000..a6160ef1 --- /dev/null +++ b/src/com/android/tv/tuner/setup/ScanResultFragment.java @@ -0,0 +1,133 @@ +/* + * 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() { + return new ContentFragment(); + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + + @Override + protected boolean needsDoneButton() { + return false; + } + + 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.getQuantityString( + R.plurals.ut_result_found_description, + mChannelCountOnPreference, + mChannelCountOnPreference); + breadcrumb = null; + } else { + Bundle args = getArguments(); + int tunerType = + (args == null ? 0 : args.getInt(TunerSetupActivity.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/src/com/android/tv/tuner/setup/TunerSetupActivity.java b/src/com/android/tv/tuner/setup/TunerSetupActivity.java new file mode 100644 index 00000000..58cfc927 --- /dev/null +++ b/src/com/android/tv/tuner/setup/TunerSetupActivity.java @@ -0,0 +1,548 @@ +/* + * 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.FragmentManager; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.tv.TvContract; +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.view.KeyEvent; +import android.widget.Toast; +import com.android.tv.Features; +import com.android.tv.TvApplication; +import com.android.tv.common.AutoCloseableUtils; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonConstants; +import com.android.tv.common.TvCommonUtils; +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.experiments.Experiments; +import com.android.tv.tuner.R; +import com.android.tv.tuner.TunerHal; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.tvinput.TunerTvInputService; +import com.android.tv.tuner.util.PostalCodeUtils; +import java.util.concurrent.Executor; + +/** An activity that serves tuner setup process. */ +public class TunerSetupActivity extends SetupActivity { + private static final String TAG = "TunerSetupActivity"; + 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. + private static final String TV_ACTIVITY_CLASS_NAME = "com.android.tv.TvActivity"; + private static final String TUNER_SET_UP_NOTIFICATION_CHANNEL_ID = "tuner_setup_channel"; + private static final String NOTIFY_TAG = "TunerSetup"; + private static final int NOTIFY_ID = 1000; + private static final String TAG_DRAWABLE = "drawable"; + private static final String TAG_ICON = "ic_launcher_s"; + private static final int PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION = 1; + + private 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 + }; + + private ScanFragment mLastScanFragment; + private Integer mTunerType; + private TunerHalFactory mTunerHalFactory; + private boolean mNeedToShowPostalCodeFragment; + private String mPreviousPostalCode; + + @Override + protected void onCreate(Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreate"); + new AsyncTask<Void, Void, Integer>() { + @Override + protected Integer doInBackground(Void... arg0) { + return TunerHal.getTunerTypeAndCount(TunerSetupActivity.this).first; + } + + @Override + protected void onPostExecute(Integer result) { + if (!TunerSetupActivity.this.isDestroyed()) { + mTunerType = result; + if (result == null) { + finish(); + } else { + showInitialFragment(); + } + } + } + }.execute(); + TvApplication.setCurrentRunningProcess(this, false); + 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); + } + mTunerHalFactory = new TunerHalFactory(getApplicationContext()); + 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); + } + } + + @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: + if (mNeedToShowPostalCodeFragment + || Features.ENABLE_CLOUD_EPG_REGION.isEnabled( + getApplicationContext()) + && TextUtils.isEmpty( + PostalCodeUtils.getLastPostalCode(this))) { + // 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: + if (actionId == SetupMultiPaneFragment.ACTION_DONE + || actionId == SetupMultiPaneFragment.ACTION_SKIP) { + showConnectionTypeFragment(); + } + 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(); + SetupFragment fragment = new ScanResultFragment(); + Bundle args2 = new Bundle(); + args2.putInt(KEY_TUNER_TYPE, mTunerType); + fragment.setArguments(args2); + fragment.setShortDistance( + SetupFragment.FRAGMENT_EXIT_TRANSITION + | SetupFragment.FRAGMENT_REENTER_TRANSITION); + showFragment(fragment, true); + return true; + } + break; + case ScanResultFragment.ACTION_CATEGORY: + switch (actionId) { + case SetupMultiPaneFragment.ACTION_DONE: + setResult(RESULT_OK); + finish(); + break; + default: + SetupFragment fragment = new ConnectionTypeFragment(); + fragment.setShortDistance( + SetupFragment.FRAGMENT_ENTER_TRANSITION + | SetupFragment.FRAGMENT_RETURN_TRANSITION); + showFragment(fragment, true); + break; + } + return true; + } + return false; + } + + @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) { + // Pops fragment including ScanFragment. + manager.popBackStack( + manager.getBackStackEntryAt(count - 2).getName(), + FragmentManager.POP_BACK_STACK_INCLUSIVE); + return true; + } else if (ScanFragment.class.getCanonicalName().equals(lastTag)) { + mLastScanFragment.finishScan(true); + return true; + } + } + } + return super.onKeyUp(keyCode, event); + } + + @Override + public void onDestroy() { + if (mPreviousPostalCode != null && PostalCodeUtils.getLastPostalCode(this) == null) { + PostalCodeUtils.setLastPostalCode(this, mPreviousPostalCode); + } + super.onDestroy(); + } + + /** + * 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); + } + } + + /** + * Returns a {@link Intent} to launch the tuner TV input service. + * + * @param context a {@link Context} instance + */ + public static Intent createSetupActivity(Context context) { + String inputId = + TvContract.buildInputId( + new ComponentName( + context.getPackageName(), TunerTvInputService.class.getName())); + + // Make an intent to launch the setup activity of TV tuner input. + Intent intent = + TvCommonUtils.createSetupIntent( + new Intent(context, TunerSetupActivity.class), inputId); + intent.putExtra(TvCommonConstants.EXTRA_INPUT_ID, inputId); + Intent tvActivityIntent = new Intent(); + tvActivityIntent.setComponent(new ComponentName(context, TV_ACTIVITY_CLASS_NAME)); + intent.putExtra(TvCommonConstants.EXTRA_ACTIVITY_AFTER_COMPLETION, tvActivityIntent); + return intent; + } + + /** Gets the currently used tuner HAL. */ + TunerHal getTunerHal() { + return mTunerHalFactory.getOrCreate(); + } + + /** Generates tuner HAL. */ + void generateTunerHal() { + mTunerHalFactory.generate(); + } + + /** Clears the currently used tuner HAL. */ + void clearTunerHal() { + mTunerHalFactory.clear(); + } + + /** + * 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, createSetupActivity(context), PendingIntent.FLAG_UPDATE_CURRENT); + } + + 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; + } + 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); + } + } + + /** + * 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); + } + + 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); + } + + private void showPostalCodeFragment() { + SetupFragment fragment = new PostalCodeFragment(); + fragment.setShortDistance( + SetupFragment.FRAGMENT_ENTER_TRANSITION | SetupFragment.FRAGMENT_RETURN_TRANSITION); + showFragment(fragment, true); + } + + private void showConnectionTypeFragment() { + SetupFragment fragment = new ConnectionTypeFragment(); + fragment.setShortDistance( + SetupFragment.FRAGMENT_ENTER_TRANSITION | SetupFragment.FRAGMENT_RETURN_TRANSITION); + showFragment(fragment, 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); + } + + @VisibleForTesting + static class TunerHalFactory { + private Context mContext; + @VisibleForTesting TunerHal mTunerHal; + private 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 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/src/com/android/tv/tuner/setup/WelcomeFragment.java b/src/com/android/tv/tuner/setup/WelcomeFragment.java new file mode 100644 index 00000000..326fe126 --- /dev/null +++ b/src/com/android/tv/tuner/setup/WelcomeFragment.java @@ -0,0 +1,127 @@ +/* + * 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; + } + + 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( + TunerSetupActivity.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/src/com/android/tv/tuner/source/FileTsStreamer.java b/src/com/android/tv/tuner/source/FileTsStreamer.java new file mode 100644 index 00000000..f74274f4 --- /dev/null +++ b/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.Features; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.ChannelScanFileParser.ScanChannel; +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, Features.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 (Features.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 (Features.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/src/com/android/tv/tuner/source/TsDataSource.java b/src/com/android/tv/tuner/source/TsDataSource.java new file mode 100644 index 00000000..be902944 --- /dev/null +++ b/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/src/com/android/tv/tuner/source/TsDataSourceManager.java b/src/com/android/tv/tuner/source/TsDataSourceManager.java new file mode 100644 index 00000000..fc8a8327 --- /dev/null +++ b/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.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/src/com/android/tv/tuner/source/TsStreamWriter.java b/src/com/android/tv/tuner/source/TsStreamWriter.java new file mode 100644 index 00000000..f90136bf --- /dev/null +++ b/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/src/com/android/tv/tuner/source/TsStreamer.java b/src/com/android/tv/tuner/source/TsStreamer.java new file mode 100644 index 00000000..3dbba7e7 --- /dev/null +++ b/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/src/com/android/tv/tuner/source/TunerTsStreamer.java b/src/com/android/tv/tuner/source/TunerTsStreamer.java new file mode 100644 index 00000000..21b7a1f8 --- /dev/null +++ b/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/src/com/android/tv/tuner/source/TunerTsStreamerManager.java b/src/com/android/tv/tuner/source/TunerTsStreamerManager.java new file mode 100644 index 00000000..e94bd56c --- /dev/null +++ b/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.AutoCloseableUtils; +import com.android.tv.common.SoftPreconditions; +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 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 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/src/com/android/tv/tuner/ts/SectionParser.java b/src/com/android/tv/tuner/ts/SectionParser.java new file mode 100644 index 00000000..6d0eb90f --- /dev/null +++ b/src/com/android/tv/tuner/ts/SectionParser.java @@ -0,0 +1,2083 @@ +/* + * 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.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 com.ibm.icu.text.UnicodeDecompressor; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +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. + } + + // 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.SERVICE_TYPE_ATSC_AUDIO + || serviceType == Channel.SERVICE_TYPE_ATSC_DIGITAL_TELEVISION + || serviceType + == Channel.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_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; + } + byte[] bytes = Arrays.copyOfRange(data, pos + 3, pos + 3 + numBytes); + if (compressionType == COMPRESSION_TYPE_NO_COMPRESSION) { + try { + switch (mode) { + case MODE_SELECTED_UNICODE_RANGE_1: + return new String(bytes, "ISO-8859-1"); + case MODE_SCSU: + return UnicodeDecompressor.decompress(bytes); + case MODE_UTF16: + return new String(bytes, "UTF-16"); + } + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Unsupported text format.", e); + } + } + 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/src/com/android/tv/tuner/ts/TsParser.java b/src/com/android/tv/tuner/ts/TsParser.java new file mode 100644 index 00000000..fbedc2c3 --- /dev/null +++ b/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 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/src/com/android/tv/tuner/tvinput/ChannelDataManager.java b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java new file mode 100644 index 00000000..49fc0ca1 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java @@ -0,0 +1,801 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.tuner.tvinput; + +import android.content.ComponentName; +import android.content.ContentProviderOperation; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.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.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 com.android.tv.util.PermissionUtils; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Manages the channel info and EPG data through {@link TvInputManager}. */ +public class ChannelDataManager implements Handler.Callback { + private static final String TAG = "ChannelDataManager"; + + private static final String[] ALL_PROGRAMS_SELECTION_ARGS = + new String[] { + TvContract.Programs._ID, + TvContract.Programs.COLUMN_TITLE, + TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, + TvContract.Programs.COLUMN_CONTENT_RATING, + TvContract.Programs.COLUMN_BROADCAST_GENRE, + TvContract.Programs.COLUMN_CANONICAL_GENRE, + TvContract.Programs.COLUMN_SHORT_DESCRIPTION, + TvContract.Programs.COLUMN_VERSION_NUMBER + }; + private static final String[] CHANNEL_DATA_SELECTION_ARGS = + new String[] { + TvContract.Channels._ID, + TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, + TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1 + }; + + private static final int MSG_HANDLE_EVENTS = 1; + private static final int MSG_HANDLE_CHANNEL = 2; + private static final int MSG_BUILD_CHANNEL_MAP = 3; + private static final int MSG_REQUEST_PROGRAMS = 4; + private static final int MSG_CLEAR_CHANNELS = 6; + private static final int MSG_CHECK_VERSION = 7; + + // Throttle the batch operations to avoid TransactionTooLargeException. + private static final int BATCH_OPERATION_COUNT = 100; + // At most 16 days of program information is delivered through an EIT, + // according to the Chapter 6.4 of ATSC Recommended Practice A/69. + private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(16); + + /** + * A version number to enforce consistency of the channel data. + * + * <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 = + TvContract.buildInputId( + new ComponentName( + mContext.getPackageName(), TunerTvInputService.class.getName())); + mChannelsUri = TvContract.buildChannelsUriForInput(mInputId); + mTunerChannelMap = new ConcurrentHashMap<>(); + mTunerChannelIdMap = new ConcurrentSkipListMap<>(); + mHandlerThread = new HandlerThread("TvInputServiceBackgroundThread"); + mHandlerThread.start(); + mHandler = new Handler(mHandlerThread.getLooper(), this); + mIsScanning = new AtomicBoolean(); + mScannedChannels = new ConcurrentSkipListSet<>(); + mPreviousScannedChannels = new ConcurrentSkipListSet<>(); + } + + // Public methods + public void checkDataVersion(Context context) { + int version = TunerPreferences.getChannelDataVersion(context); + Log.d(TAG, "ChannelDataManager.VERSION=" + VERSION + " (current=" + version + ")"); + if (version == VERSION) { + // Everything is awesome. Return and continue. + return; + } + setCurrentVersion(context); + + if (version == TunerPreferences.CHANNEL_DATA_VERSION_NOT_SET) { + mHandler.sendEmptyMessage(MSG_CHECK_VERSION); + } else { + // The stored channel data seem outdated. Delete them all. + mHandler.sendEmptyMessage(MSG_CLEAR_CHANNELS); + } + } + + public void setCurrentVersion(Context context) { + TunerPreferences.setChannelDataVersion(context, VERSION); + } + + public void setListener(ProgramInfoListener listener) { + mListener = listener; + } + + public void setChannelScanListener(ChannelScanListener listener, Handler handler) { + mChannelScanListener = listener; + mChannelScanHandler = handler; + } + + public void release() { + mHandler.removeCallbacksAndMessages(null); + 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; + try (Cursor cursor = + mContext.getContentResolver() + .query( + TvContract.buildChannelUri(channelId), + CHANNEL_DATA_SELECTION_ARGS, + null, + null, + null)) { + if (cursor != null && cursor.moveToFirst()) { + data = cursor.getBlob(1); + } + } + if (data == null) { + return null; + } + channel = TunerChannel.parseFrom(data); + if (channel == null) { + return null; + } + channel.setChannelId(channelId); + return channel; + } + + public void requestProgramsData(TunerChannel channel) { + mHandler.removeMessages(MSG_REQUEST_PROGRAMS); + mHandler.obtainMessage(MSG_REQUEST_PROGRAMS, channel).sendToTarget(); + } + + public void notifyEventDetected(TunerChannel channel, List<EitItem> items) { + mHandler.obtainMessage(MSG_HANDLE_EVENTS, new ChannelEvent(channel, items)).sendToTarget(); + } + + public void notifyChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) { + if (mIsScanning.get()) { + // During scanning, channels should be handle first to improve scan time. + // EIT items can be handled in background after channel scan. + mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(MSG_HANDLE_CHANNEL, channel)); + } else { + mHandler.obtainMessage(MSG_HANDLE_CHANNEL, channel).sendToTarget(); + } + } + + // For scanning process + /** + * Invoked when starting a scanning mode. This method gets the previous channels to detect the + * obsolete channels after scanning and initializes the variables used for scanning. + */ + public void notifyScanStarted() { + mScannedChannels.clear(); + mPreviousScannedChannels.clear(); + try (Cursor cursor = + mContext.getContentResolver() + .query(mChannelsUri, CHANNEL_DATA_SELECTION_ARGS, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + do { + long channelId = cursor.getLong(0); + byte[] data = cursor.getBlob(1); + TunerChannel channel = TunerChannel.parseFrom(data); + if (channel != null) { + channel.setChannelId(channelId); + mPreviousScannedChannels.add(channel); + } + } while (cursor.moveToNext()); + } + } + mIsScanning.set(true); + } + + /** + * Invoked when completing the scanning mode. Passes {@code MSG_SCAN_COMPLETED} to the handler + * in order to wait for finishing the remaining messages in the handler queue. Then removes the + * obsolete channels, which are previously scanned but are not in the current scanned result. + */ + public void notifyScanCompleted() { + // Send a dummy message to check whether there is any MSG_HANDLE_CHANNEL in queue + // and avoid race conditions. + scanCompleted.set(true); + mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(MSG_HANDLE_CHANNEL, null)); + } + + public void scannedChannelHandlingCompleted() { + mIsScanning.set(false); + if (!mPreviousScannedChannels.isEmpty()) { + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (TunerChannel channel : mPreviousScannedChannels) { + ops.add( + ContentProviderOperation.newDelete( + TvContract.buildChannelUri(channel.getChannelId())) + .build()); + } + try { + mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); + } catch (RemoteException | OperationApplicationException e) { + Log.e(TAG, "Error deleting obsolete channels", e); + } + } + if (mChannelScanListener != null && mChannelScanHandler != null) { + mChannelScanHandler.post( + new Runnable() { + @Override + public void run() { + mChannelScanListener.onChannelHandlingDone(); + } + }); + } else { + Log.e(TAG, "Error. mChannelScanListener is null."); + } + } + + /** Returns the number of scanned channels in the scanning mode. */ + public int getScannedChannelCount() { + return mScannedChannels.size(); + } + + /** + * Removes all callbacks and messages in handler to avoid previous messages from last channel. + */ + public void removeAllCallbacksAndMessages() { + mHandler.removeCallbacksAndMessages(null); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_HANDLE_EVENTS: + { + ChannelEvent event = (ChannelEvent) msg.obj; + handleEvents(event.channel, event.eitItems); + return true; + } + case MSG_HANDLE_CHANNEL: + { + TunerChannel channel = (TunerChannel) msg.obj; + if (channel != null) { + handleChannel(channel); + } + if (scanCompleted.get() + && mIsScanning.get() + && !mHandler.hasMessages(MSG_HANDLE_CHANNEL)) { + // Complete the scan when all found channels have already been handled. + scannedChannelHandlingCompleted(); + } + return true; + } + case MSG_BUILD_CHANNEL_MAP: + { + mHandler.removeMessages(MSG_BUILD_CHANNEL_MAP); + buildChannelMap(); + return true; + } + case MSG_REQUEST_PROGRAMS: + { + if (mHandler.hasMessages(MSG_REQUEST_PROGRAMS)) { + return true; + } + TunerChannel channel = (TunerChannel) msg.obj; + if (mListener != null) { + mListener.onRequestProgramsResponse( + channel, getAllProgramsForChannel(channel)); + } + return true; + } + case MSG_CLEAR_CHANNELS: + { + clearChannels(); + return true; + } + case MSG_CHECK_VERSION: + { + checkVersion(); + return true; + } + } + return false; + } + + // Private methods + private void handleEvents(TunerChannel channel, List<EitItem> items) { + long channelId = getChannelId(channel); + if (channelId <= 0) { + return; + } + channel.setChannelId(channelId); + + // Schedule the audio and caption tracks of the current program and the programs being + // listed after the current one into TIS. + if (mListener != null) { + mListener.onProgramsArrived(channel, items); + } + + long currentTime = System.currentTimeMillis(); + List<EitItem> oldItems = + getAllProgramsForChannel( + channel, currentTime, currentTime + PROGRAM_QUERY_DURATION); + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + // TODO: Find a right way to check if the programs are added outside. + boolean addedOutside = false; + for (EitItem item : oldItems) { + if (item.getEventId() == 0) { + // The event has been added outside TV tuner. + addedOutside = true; + break; + } + } + + // Inserting programs only when there is no overlapping with existing data assuming that: + // 1. external EPG is more accurate and rich and + // 2. the data we add here will be updated when we apply external EPG. + if (addedOutside) { + // oldItemCount cannot be 0 if addedOutside is true. + int oldItemCount = oldItems.size(); + for (EitItem newItem : items) { + if (newItem.getEndTimeUtcMillis() < currentTime) { + continue; + } + long newItemStartTime = newItem.getStartTimeUtcMillis(); + long newItemEndTime = newItem.getEndTimeUtcMillis(); + if (newItemStartTime < oldItems.get(0).getStartTimeUtcMillis()) { + // Start time smaller than that of any old items. Insert if no overlap. + if (newItemEndTime > oldItems.get(0).getStartTimeUtcMillis()) continue; + } else if (newItemStartTime + > oldItems.get(oldItemCount - 1).getStartTimeUtcMillis()) { + // Start time larger than that of any old item. Insert if no overlap. + if (newItemStartTime < oldItems.get(oldItemCount - 1).getEndTimeUtcMillis()) + continue; + } else { + int pos = + Collections.binarySearch( + oldItems, + newItem, + new Comparator<EitItem>() { + @Override + public int compare(EitItem lhs, EitItem rhs) { + return Long.compare( + lhs.getStartTimeUtcMillis(), + rhs.getStartTimeUtcMillis()); + } + }); + if (pos >= 0) { + // Same start Time found. Overlapped. + continue; + } + int insertPoint = -1 - pos; + // Check the two adjacent items. + if (newItemStartTime < oldItems.get(insertPoint - 1).getEndTimeUtcMillis() + || newItemEndTime > oldItems.get(insertPoint).getStartTimeUtcMillis()) { + continue; + } + } + ops.add( + buildContentProviderOperation( + ContentProviderOperation.newInsert(TvContract.Programs.CONTENT_URI), + newItem, + channel)); + 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 { + channelId = cursor.getLong(0); + byte[] providerData = cursor.getBlob(1); + TunerChannel tunerChannel = TunerChannel.parseFrom(providerData); + if (tunerChannel != null && tunerChannel.compareTo(channel) == 0) { + channel.setChannelId(channelId); + mTunerChannelIdMap.put(channel, channelId); + mTunerChannelMap.put(channelId, channel); + return channelId; + } + } while (cursor.moveToNext()); + } + } + return -1; + } + + private List<EitItem> getAllProgramsForChannel(TunerChannel channel) { + return getAllProgramsForChannel(channel, null, null); + } + + private List<EitItem> getAllProgramsForChannel( + TunerChannel channel, @Nullable Long startTimeMs, @Nullable Long endTimeMs) { + List<EitItem> items = new ArrayList<>(); + long channelId = channel.getChannelId(); + Uri programsUri = + (startTimeMs == null || endTimeMs == null) + ? TvContract.buildProgramsUriForChannel(channelId) + : TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs); + try (Cursor cursor = + mContext.getContentResolver() + .query(programsUri, ALL_PROGRAMS_SELECTION_ARGS, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + do { + long id = cursor.getLong(0); + String titleText = cursor.getString(1); + long startTime = + ConvertUtils.convertUnixEpochToGPSTime( + cursor.getLong(2) / DateUtils.SECOND_IN_MILLIS); + long endTime = + ConvertUtils.convertUnixEpochToGPSTime( + cursor.getLong(3) / DateUtils.SECOND_IN_MILLIS); + int lengthInSecond = (int) (endTime - startTime); + String contentRating = cursor.getString(4); + String broadcastGenre = cursor.getString(5); + String canonicalGenre = cursor.getString(6); + String description = cursor.getString(7); + int eventId = cursor.getInt(8); + EitItem eitItem = + new EitItem( + id, + eventId, + titleText, + startTime, + lengthInSecond, + contentRating, + null, + null, + broadcastGenre, + canonicalGenre, + description); + items.add(eitItem); + } while (cursor.moveToNext()); + } + } + return items; + } + + private void buildChannelMap() { + ArrayList<TunerChannel> channels = new ArrayList<>(); + try (Cursor cursor = + mContext.getContentResolver() + .query(mChannelsUri, CHANNEL_DATA_SELECTION_ARGS, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + do { + long channelId = cursor.getLong(0); + byte[] data = cursor.getBlob(1); + TunerChannel channel = TunerChannel.parseFrom(data); + if (channel != null) { + channel.setChannelId(channelId); + channels.add(channel); + } + } while (cursor.moveToNext()); + } + } + mTunerChannelMap.clear(); + mTunerChannelIdMap.clear(); + for (TunerChannel channel : channels) { + mTunerChannelMap.put(channel.getChannelId(), channel); + mTunerChannelIdMap.put(channel, channel.getChannelId()); + } + } + + private static class ChannelEvent { + public final TunerChannel channel; + public final List<EitItem> eitItems; + + public ChannelEvent(TunerChannel channel, List<EitItem> eitItems) { + this.channel = channel; + this.eitItems = eitItems; + } + } +} diff --git a/src/com/android/tv/tuner/tvinput/EventDetector.java b/src/com/android/tv/tuner/tvinput/EventDetector.java new file mode 100644 index 00000000..c529c6db --- /dev/null +++ b/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/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java new file mode 100644 index 00000000..f2ed72f1 --- /dev/null +++ b/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 = true; + 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/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java b/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java new file mode 100644 index 00000000..1628bcfb --- /dev/null +++ b/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/src/com/android/tv/tuner/tvinput/TunerDebug.java b/src/com/android/tv/tuner/tvinput/TunerDebug.java new file mode 100644 index 00000000..1df0b5c3 --- /dev/null +++ b/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/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java new file mode 100644 index 00000000..a1f0c773 --- /dev/null +++ b/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/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java new file mode 100644 index 00000000..1bc4e295 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.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.tvinput; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.media.tv.TvInputManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.support.annotation.IntDef; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.util.Log; +import android.util.Pair; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.recording.RecordingCapability; +import com.android.tv.dvr.DvrStorageStatusManager; +import com.android.tv.dvr.data.RecordedProgram; +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.android.tv.util.Utils; +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; + + public RecordingCapability getCapabilities() { + return mCapabilities; + } + + @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 DvrStorageStatusManager mDvrStorageStatusManager; + private final Handler mHandler; + private final TsDataSourceManager mSourceManager; + private final Random mRandom = new Random(); + + private TsDataSource mTunerSource; + private TunerChannel mChannel; + private File mStorageDir; + private long mRecordStartTime; + private long mRecordEndTime; + private boolean mRecorderRunning; + private 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); + mDvrStorageStatusManager = + TvApplication.getSingletons(context).getDvrStorageStatusManager(); + mChannelDataManager = dataManager; + mChannelDataManager.checkDataVersion(context); + mSourceManager = TsDataSourceManager.createSourceManager(true); + mCapabilities = new DvbDeviceAccessor(context).getRecordingCapability(inputId); + mInputId = inputId; + if (DEBUG) Log.d(TAG, mCapabilities.toString()); + mSession = session; + } + + // PlaybackBufferListener + @Override + public void onBufferStartTimeChanged(long startTimeMs) {} + + @Override + public void onBufferStateChanged(boolean available) {} + + @Override + public void onDiskTooSlow() {} + + // EventDetector.EventListener + @Override + public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) { + if (mChannel == null || mChannel.compareTo(channel) != 0) { + return; + } + mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime); + } + + @Override + public void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items) { + if (mChannel == null || mChannel.compareTo(channel) != 0) { + return; + } + 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 (!mDvrStorageStatusManager.isStorageSufficient()) { + if (mRecorderRunning) { + stopRecorder(); + } + new DeleteRecordingTask().execute(mStorageDir); + mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + reset(); + } else { + mHandler.sendEmptyMessageDelayed( + MSG_MONITOR_STORAGE_STATUS, STORAGE_MONITOR_INTERVAL_MS); + } + return true; + } + case MSG_RELEASE: + { + // Since release was requested, current recording will be cancelled + // without notification. + reset(); + mSourceManager.release(); + mHandler.removeCallbacksAndMessages(null); + mHandler.getLooper().quitSafely(); + return true; + } + 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 (!mDvrStorageStatusManager.isStorageSufficient()) { + mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + Log.w(TAG, "Tuning failed due to insufficient storage."); + return false; + } + mTunerSource = mSourceManager.createDataSource(mContext, mChannel, this); + if (mTunerSource == null) { + // 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 = + mDvrStorageStatusManager.isStorageSufficient() + ? new File( + mDvrStorageStatusManager.getRecordingRootDataDirectory(), + getStorageKey()) + : null; + if (mStorageDir == null) { + mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + Log.w(TAG, "Failed to start recording due to insufficient storage."); + return false; + } + // Since tuning might be happened a while ago, shifts the start position of tuned source. + mTunerSource.shiftStartPosition(mTunerSource.getBufferedPosition()); + 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 static class Program { + private final long mChannelId; + private final String mTitle; + private String mSeriesId; + private final String mSeasonTitle; + private final String mEpisodeTitle; + private final String mSeasonNumber; + private final String mEpisodeNumber; + private final String mDescription; + private final String mPosterArtUri; + private final String mThumbnailUri; + private final String mCanonicalGenres; + private final String mContentRatings; + private final long mStartTimeUtcMillis; + private final long mEndTimeUtcMillis; + private final int mVideoWidth; + private final int mVideoHeight; + private final byte[] mInternalProviderData; + + private static final String[] PROJECTION = { + TvContract.Programs.COLUMN_CHANNEL_ID, + TvContract.Programs.COLUMN_TITLE, + TvContract.Programs.COLUMN_SEASON_TITLE, + TvContract.Programs.COLUMN_EPISODE_TITLE, + TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER, + TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER, + TvContract.Programs.COLUMN_SHORT_DESCRIPTION, + TvContract.Programs.COLUMN_POSTER_ART_URI, + TvContract.Programs.COLUMN_THUMBNAIL_URI, + TvContract.Programs.COLUMN_CANONICAL_GENRE, + TvContract.Programs.COLUMN_CONTENT_RATING, + TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, + TvContract.Programs.COLUMN_VIDEO_WIDTH, + TvContract.Programs.COLUMN_VIDEO_HEIGHT, + TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA + }; + + public Program(Cursor cursor) { + int index = 0; + mChannelId = cursor.getLong(index++); + mTitle = cursor.getString(index++); + mSeasonTitle = cursor.getString(index++); + mEpisodeTitle = cursor.getString(index++); + mSeasonNumber = cursor.getString(index++); + mEpisodeNumber = cursor.getString(index++); + mDescription = cursor.getString(index++); + mPosterArtUri = cursor.getString(index++); + mThumbnailUri = cursor.getString(index++); + mCanonicalGenres = cursor.getString(index++); + mContentRatings = cursor.getString(index++); + mStartTimeUtcMillis = cursor.getLong(index++); + mEndTimeUtcMillis = cursor.getLong(index++); + mVideoWidth = cursor.getInt(index++); + mVideoHeight = cursor.getInt(index++); + mInternalProviderData = cursor.getBlob(index++); + SoftPreconditions.checkArgument(index == PROJECTION.length); + } + + public Program(long channelId) { + mChannelId = channelId; + mTitle = "Unknown"; + mSeasonTitle = ""; + mEpisodeTitle = ""; + mSeasonNumber = ""; + mEpisodeNumber = ""; + mDescription = "Unknown"; + mPosterArtUri = null; + mThumbnailUri = null; + mCanonicalGenres = null; + mContentRatings = null; + mStartTimeUtcMillis = 0; + mEndTimeUtcMillis = 0; + mVideoWidth = 0; + mVideoHeight = 0; + mInternalProviderData = null; + } + + public static Program onQuery(Cursor c) { + Program program = null; + if (c != null && c.moveToNext()) { + program = new Program(c); + } + return program; + } + + public ContentValues buildValues() { + ContentValues values = new ContentValues(); + int index = 0; + values.put(PROJECTION[index++], mChannelId); + values.put(PROJECTION[index++], mTitle); + values.put(PROJECTION[index++], mSeasonTitle); + values.put(PROJECTION[index++], mEpisodeTitle); + values.put(PROJECTION[index++], mSeasonNumber); + values.put(PROJECTION[index++], mEpisodeNumber); + values.put(PROJECTION[index++], mDescription); + values.put(PROJECTION[index++], mPosterArtUri); + values.put(PROJECTION[index++], mThumbnailUri); + values.put(PROJECTION[index++], mCanonicalGenres); + values.put(PROJECTION[index++], mContentRatings); + values.put(PROJECTION[index++], mStartTimeUtcMillis); + values.put(PROJECTION[index++], mEndTimeUtcMillis); + values.put(PROJECTION[index++], mVideoWidth); + values.put(PROJECTION[index++], mVideoHeight); + values.put(PROJECTION[index++], mInternalProviderData); + SoftPreconditions.checkArgument(index == PROJECTION.length); + return values; + } + } + + private Program getRecordedProgram() { + ContentResolver resolver = mContext.getContentResolver(); + Uri programUri = mProgramUri; + if (mProgramUri == null) { + long avg = mRecordStartTime / 2 + mRecordEndTime / 2; + programUri = TvContract.buildProgramsUriForChannel(mChannel.getChannelId(), avg, avg); + } + try (Cursor c = resolver.query(programUri, Program.PROJECTION, null, null, SORT_BY_TIME)) { + if (c != null) { + Program result = Program.onQuery(c); + if (DEBUG) { + Log.v(TAG, "Finished query for " + this); + } + return result; + } else { + if (c == null) { + Log.e(TAG, "Unknown query error for " + this); + } else { + if (DEBUG) Log.d(TAG, "Canceled query for " + this); + } + return null; + } + } + } + + private Uri insertRecordedProgram( + Program program, + long channelId, + String storageUri, + long totalBytes, + long startTime, + long endTime) { + // TODO: Set title even though program is null. + RecordedProgram recordedProgram = + RecordedProgram.builder() + .setInputId(mInputId) + .setChannelId(channelId) + .setDataUri(storageUri) + .setDurationMillis(endTime - startTime) + .setDataBytes(totalBytes) + // startTime and endTime could be overridden by program's start and end + // value. + .setStartTimeUtcMillis(startTime) + .setEndTimeUtcMillis(endTime) + .build(); + ContentValues values = RecordedProgram.toValues(recordedProgram); + if (program != null) { + values.putAll(program.buildValues()); + } + return mContext.getContentResolver() + .insert(TvContract.RecordedPrograms.CONTENT_URI, values); + } + + private void onRecordingResult(boolean success, long lastExtractedPositionUs) { + if (mSessionState != STATE_RECORDING) { + // Error notification is not needed. + Log.e(TAG, "Recording session status abnormal"); + return; + } + if (mRecorderRunning) { + // In case of recorder not being stopped, because of premature termination of recording. + stopRecorder(); + } + if (!success + && lastExtractedPositionUs + < TimeUnit.MILLISECONDS.toMicros(MIN_PARTIAL_RECORDING_DURATION_MS)) { + new DeleteRecordingTask().execute(mStorageDir); + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + Log.w(TAG, "Recording failed during recording"); + return; + } + Log.i(TAG, "recording finished " + (success ? "completely" : "partially")); + 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) { + Utils.deleteDirOrFile(file); + } + return null; + } + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerSession.java b/src/com/android/tv/tuner/tvinput/TunerSession.java new file mode 100644 index 00000000..eec5da1f --- /dev/null +++ b/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.tuner.R; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.TunerPreferences.TunerPreferencesChangedListener; +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.android.tv.tuner.util.SystemPropertiesProxy; +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, TunerPreferencesChangedListener { + 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.setTunerPreferencesChangedListener(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.setTunerPreferencesChangedListener(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 onTunerPreferencesChanged() { + mSessionWorker.sendMessage(TunerSessionWorker.MSG_TUNER_PREFERENCES_CHANGED); + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java new file mode 100644 index 00000000..7a0897e2 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java @@ -0,0 +1,1852 @@ +/* + * 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.SoftPreconditions; +import com.android.tv.common.TvContentRatingCache; +import com.android.tv.customization.TvCustomizationManager; +import com.android.tv.customization.TvCustomizationManager.TRICKPLAY_MODE; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.TunerPreferences.TrickplaySetting; +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.exoplayer.ffmpeg.FfmpegDecoderClient; +import com.android.tv.tuner.source.TsDataSource; +import com.android.tv.tuner.source.TsDataSourceManager; +import com.android.tv.tuner.util.StatusTextUtils; +import com.android.tv.tuner.util.SystemPropertiesProxy; +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 = TvCustomizationManager.getTrickplayMode(context); + if (mTrickplayModeCustomization + == TvCustomizationManager.TRICKPLAY_MODE_USE_EXTERNAL_STORAGE) { + boolean useExternalStorage = + Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) + && Environment.isExternalStorageRemovable(); + mTrickplayBufferDir = useExternalStorage ? context.getExternalCacheDir() : null; + } else if (mTrickplayModeCustomization == TvCustomizationManager.TRICKPLAY_MODE_ENABLED) { + mTrickplayBufferDir = context.getCacheDir(); + } else { + mTrickplayBufferDir = null; + } + mTrickplayDisabledByStorageIssue = mTrickplayBufferDir == null; + mTrickplaySetting = TunerPreferences.getTrickplaySetting(context); + if (mTrickplaySetting != TunerPreferences.TRICKPLAY_SETTING_NOT_SET + && mTrickplayModeCustomization + == TvCustomizationManager.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 = FfmpegDecoderClient.connect(context); + } + + // 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) { + FfmpegDecoderClient.disconnect(mContext); + } + 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++); + 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) { + 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 (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.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 != TvCustomizationManager.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 == TvCustomizationManager.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.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, 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); + } + + 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.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/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java new file mode 100644 index 00000000..f014d568 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java @@ -0,0 +1,177 @@ +/* + * 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.TvApplication; +import com.android.tv.dvr.DvrStorageStatusManager; +import com.android.tv.util.Utils; +import java.io.File; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Creates {@link JobService} to clean up recorded program files which are not referenced from + * database. + */ +public class TunerStorageCleanUpService extends JobService { + private static final String TAG = "TunerStorageCleanUpService"; + + private CleanUpStorageTask mTask; + + @Override + public void onCreate() { + if (!TvApplication.getSingletons(this).getTvInputManagerHelper().hasTvInputManager()) { + Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); + this.stopSelf(); + return; + } + TvApplication.setCurrentRunningProcess(this, false); + super.onCreate(); + mTask = new CleanUpStorageTask(this, this); + } + + @Override + public boolean onStartJob(JobParameters params) { + mTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, params); + return true; + } + + @Override + public boolean onStopJob(JobParameters params) { + return false; + } + + /** + * Cleans up recorded program files which are not referenced from database. Cleaning up will be + * done periodically. + */ + public static class CleanUpStorageTask extends AsyncTask<JobParameters, Void, JobParameters[]> { + private 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 DvrStorageStatusManager mDvrStorageStatusManager; + private final JobService mJobService; + private final ContentResolver mContentResolver; + + /** + * Creates a recurring storage cleaning task. + * + * @param context {@link Context} + * @param jobService {@link JobService} + */ + public CleanUpStorageTask(Context context, JobService jobService) { + mContext = context; + mDvrStorageStatusManager = + TvApplication.getSingletons(mContext).getDvrStorageStatusManager(); + mJobService = jobService; + mContentResolver = mContext.getContentResolver(); + } + + private Set<String> getRecordedProgramsDirs() { + try (Cursor c = + mContentResolver.query( + TvContract.RecordedPrograms.CONTENT_URI, + mProjection, + null, + null, + null)) { + if (c == null) { + return null; + } + Set<String> recordedProgramDirs = new HashSet<>(); + while (c.moveToNext()) { + String packageName = c.getString(0); + String dataUriString = c.getString(1); + if (dataUriString == null) { + continue; + } + Uri dataUri = Uri.parse(dataUriString); + if (!Utils.isInBundledPackageSet(packageName) + || dataUri == null + || dataUri.getPath() == null + || !ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())) { + continue; + } + File recordedProgramDir = new File(dataUri.getPath()); + try { + recordedProgramDirs.add(recordedProgramDir.getCanonicalPath()); + } catch (IOException | SecurityException e) { + } + } + return recordedProgramDirs; + } + } + + @Override + protected JobParameters[] doInBackground(JobParameters... params) { + if (mDvrStorageStatusManager.getDvrStorageStatus() + == DvrStorageStatusManager.STORAGE_STATUS_MISSING) { + return params; + } + File dvrRecordingDir = mDvrStorageStatusManager.getRecordingRootDataDirectory(); + if (dvrRecordingDir == null || !dvrRecordingDir.isDirectory()) { + return params; + } + Set<String> recordedProgramDirs = getRecordedProgramsDirs(); + if (recordedProgramDirs == null) { + return params; + } + File[] files = dvrRecordingDir.listFiles(); + if (files == null || files.length == 0) { + return params; + } + for (File recordingDir : files) { + try { + if (!recordedProgramDirs.contains(recordingDir.getCanonicalPath())) { + long lastModified = recordingDir.lastModified(); + long now = System.currentTimeMillis(); + if (lastModified != 0 && lastModified < now - ELAPSED_MILLIS_TO_DELETE) { + // To prevent current recordings from being deleted, + // deletes recordings which was not modified for long enough time. + Utils.deleteDirOrFile(recordingDir); + } + } + } catch (IOException | SecurityException e) { + // would not happen + } + } + return params; + } + + @Override + protected void onPostExecute(JobParameters[] params) { + for (JobParameters param : params) { + mJobService.jobFinished(param, false); + } + } + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerTvInputService.java b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java new file mode 100644 index 00000000..a1596e3b --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.tuner.tvinput; + +import android.app.job.JobInfo; +import android.app.job.JobScheduler; +import android.content.ComponentName; +import android.content.Context; +import android.media.tv.TvContract; +import android.media.tv.TvInputService; +import android.util.Log; +import com.android.tv.TvApplication; +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 TunerTvInputService} serves TV channels coming from a tuner device. */ +public class TunerTvInputService extends TvInputService + implements AudioCapabilitiesReceiver.Listener { + private static final String TAG = "TunerTvInputService"; + private static final boolean DEBUG = false; + + private static final 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 (!TvApplication.getSingletons(this).getTvInputManagerHelper().hasTvInputManager()) { + Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); + this.stopSelf(); + return; + } + TvApplication.setCurrentRunningProcess(this, false); + 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 { + 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); + } + } + } + + public static String getInputId(Context context) { + return TvContract.buildInputId(new ComponentName(context, TunerTvInputService.class)); + } +} diff --git a/src/com/android/tv/tuner/util/ByteArrayBuffer.java b/src/com/android/tv/tuner/util/ByteArrayBuffer.java new file mode 100644 index 00000000..c3e38443 --- /dev/null +++ b/src/com/android/tv/tuner/util/ByteArrayBuffer.java @@ -0,0 +1,152 @@ +/* + * $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/>. + * + */ + +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/src/com/android/tv/tuner/util/ConvertUtils.java b/src/com/android/tv/tuner/util/ConvertUtils.java new file mode 100644 index 00000000..4b7fbdae --- /dev/null +++ b/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/src/com/android/tv/tuner/util/GlobalSettingsUtils.java b/src/com/android/tv/tuner/util/GlobalSettingsUtils.java new file mode 100644 index 00000000..98463f3b --- /dev/null +++ b/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/src/com/android/tv/tuner/util/Ints.java b/src/com/android/tv/tuner/util/Ints.java new file mode 100644 index 00000000..74e0ca8d --- /dev/null +++ b/src/com/android/tv/tuner/util/Ints.java @@ -0,0 +1,26 @@ +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/src/com/android/tv/tuner/util/PostalCodeUtils.java b/src/com/android/tv/tuner/util/PostalCodeUtils.java new file mode 100644 index 00000000..502c5648 --- /dev/null +++ b/src/com/android/tv/tuner/util/PostalCodeUtils.java @@ -0,0 +1,138 @@ +/* + * 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 android.content.Context; +import android.location.Address; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.util.LocationUtils; +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** A utility class to update, get, and set the last known postal or zip code. */ +public class PostalCodeUtils { + private static final String TAG = "PostalCodeUtils"; + + // Postcode formats, where A signifies a letter and 9 a digit: + // US zip code format: 99999 + private static final String POSTCODE_REGEX_US = "^(\\d{5})"; + // UK postcode district formats: A9, A99, AA9, AA99 + // Full UK postcode format: Postcode District + space + 9AA + // Should be able to handle both postcode district and full postcode + private static final String POSTCODE_REGEX_GB = + "^([A-Z][A-Z]?[0-9][0-9A-Z]?)( ?[0-9][A-Z]{2})?$"; + private static final String POSTCODE_REGEX_GB_GIR = "^GIR( ?0AA)?$"; // special UK postcode + + private static final Map<String, Pattern> REGION_PATTERN = new HashMap<>(); + private static final Map<String, Integer> REGION_MAX_LENGTH = new HashMap<>(); + + static { + REGION_PATTERN.put(Locale.US.getCountry(), Pattern.compile(POSTCODE_REGEX_US)); + REGION_PATTERN.put( + Locale.UK.getCountry(), + Pattern.compile(POSTCODE_REGEX_GB + "|" + POSTCODE_REGEX_GB_GIR)); + REGION_MAX_LENGTH.put(Locale.US.getCountry(), 5); + REGION_MAX_LENGTH.put(Locale.UK.getCountry(), 8); + } + + // The longest postcode number is 10-character-long. + // Use a larger number to accommodate future changes. + private static final int DEFAULT_MAX_LENGTH = 16; + + /** Returns {@code true} if postal code has been changed */ + public static boolean updatePostalCode(Context context) + throws IOException, SecurityException, NoPostalCodeException { + String postalCode = getPostalCode(context); + String lastPostalCode = getLastPostalCode(context); + if (TextUtils.isEmpty(postalCode)) { + if (TextUtils.isEmpty(lastPostalCode)) { + throw new NoPostalCodeException(); + } + } else if (!TextUtils.equals(postalCode, lastPostalCode)) { + setLastPostalCode(context, postalCode); + return true; + } + return false; + } + + /** + * Gets the last stored postal or zip code, which might be decided by {@link LocationUtils} or + * input by users. + */ + public static String getLastPostalCode(Context context) { + return TunerPreferences.getLastPostalCode(context); + } + + /** + * Sets the last stored postal or zip code. This method will overwrite the value written by + * calling {@link #updatePostalCode(Context)}. + */ + public static void setLastPostalCode(Context context, String postalCode) { + Log.i(TAG, "Set Postal Code:" + postalCode); + TunerPreferences.setLastPostalCode(context, postalCode); + } + + @Nullable + private static String getPostalCode(Context context) throws IOException, SecurityException { + Address address = LocationUtils.getCurrentAddress(context); + if (address != null) { + Log.i( + TAG, + "Current country and postal code is " + + address.getCountryName() + + ", " + + address.getPostalCode()); + return address.getPostalCode(); + } + return null; + } + + /** An {@link java.lang.Exception} class to notify no valid postal or zip code is available. */ + public static class NoPostalCodeException extends Exception { + public NoPostalCodeException() {} + } + + /** + * Checks whether a postcode matches the format of the specific region. + * + * @return {@code false} if the region is supported and the postcode doesn't match; {@code true} + * otherwise + */ + public static boolean matches(@NonNull CharSequence postcode, @NonNull String region) { + Pattern pattern = REGION_PATTERN.get(region.toUpperCase()); + return pattern == null || pattern.matcher(postcode).matches(); + } + + /** + * Gets the largest possible postcode length in the region. + * + * @return maximum postcode length if the region is supported; {@link #DEFAULT_MAX_LENGTH} + * otherwise + */ + public static int getRegionMaxLength(Context context) { + Integer maxLength = + REGION_MAX_LENGTH.get(LocationUtils.getCurrentCountry(context).toUpperCase()); + return maxLength == null ? DEFAULT_MAX_LENGTH : maxLength; + } +} diff --git a/src/com/android/tv/tuner/util/StatusTextUtils.java b/src/com/android/tv/tuner/util/StatusTextUtils.java new file mode 100644 index 00000000..84e2fc5a --- /dev/null +++ b/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/src/com/android/tv/tuner/util/SystemPropertiesProxy.java b/src/com/android/tv/tuner/util/SystemPropertiesProxy.java new file mode 100644 index 00000000..5c23c797 --- /dev/null +++ b/src/com/android/tv/tuner/util/SystemPropertiesProxy.java @@ -0,0 +1,79 @@ +/* + * 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 android.util.Log; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Proxy class that gives an access to a hidden API {@link android.os.SystemProperties#getBoolean}. + */ +public class SystemPropertiesProxy { + private static final String TAG = "SystemPropertiesProxy"; + + private SystemPropertiesProxy() {} + + public static boolean getBoolean(String key, boolean def) throws IllegalArgumentException { + try { + Class SystemPropertiesClass = Class.forName("android.os.SystemProperties"); + Method getBooleanMethod = + SystemPropertiesClass.getDeclaredMethod( + "getBoolean", String.class, boolean.class); + getBooleanMethod.setAccessible(true); + return (boolean) getBooleanMethod.invoke(SystemPropertiesClass, key, def); + } catch (InvocationTargetException + | IllegalAccessException + | NoSuchMethodException + | ClassNotFoundException e) { + Log.e(TAG, "Failed to invoke SystemProperties.getBoolean()", e); + } + return def; + } + + public static int getInt(String key, int def) throws IllegalArgumentException { + try { + Class SystemPropertiesClass = Class.forName("android.os.SystemProperties"); + Method getIntMethod = + SystemPropertiesClass.getDeclaredMethod("getInt", String.class, int.class); + getIntMethod.setAccessible(true); + return (int) getIntMethod.invoke(SystemPropertiesClass, key, def); + } catch (InvocationTargetException + | IllegalAccessException + | NoSuchMethodException + | ClassNotFoundException e) { + Log.e(TAG, "Failed to invoke SystemProperties.getInt()", e); + } + return def; + } + + public static String getString(String key, String def) throws IllegalArgumentException { + try { + Class SystemPropertiesClass = Class.forName("android.os.SystemProperties"); + Method getIntMethod = + SystemPropertiesClass.getDeclaredMethod("get", String.class, String.class); + getIntMethod.setAccessible(true); + return (String) getIntMethod.invoke(SystemPropertiesClass, key, def); + } catch (InvocationTargetException + | IllegalAccessException + | NoSuchMethodException + | ClassNotFoundException e) { + Log.e(TAG, "Failed to invoke SystemProperties.get()", e); + } + return def; + } +} diff --git a/src/com/android/tv/tuner/util/TisConfiguration.java b/src/com/android/tv/tuner/util/TisConfiguration.java new file mode 100644 index 00000000..8f1326ce --- /dev/null +++ b/src/com/android/tv/tuner/util/TisConfiguration.java @@ -0,0 +1,20 @@ +package com.android.tv.tuner.util; + +import android.content.Context; + +/** A helper class of tuner configuration. */ +public class TisConfiguration { + private static final String LC_PACKAGE_NAME = "com.android.tv"; + + public static boolean isPackagedWithLiveChannels(Context context) { + return (LC_PACKAGE_NAME.equals(context.getPackageName())); + } + + public static boolean isInternalTunerTvInput(Context context) { + return (!LC_PACKAGE_NAME.equals(context.getPackageName())); + } + + public static int getTunerHwDeviceId(Context context) { + return 0; // FIXME: Make it OEM configurable + } +} diff --git a/src/com/android/tv/tuner/util/TunerInputInfoUtils.java b/src/com/android/tv/tuner/util/TunerInputInfoUtils.java new file mode 100644 index 00000000..9a3eec2b --- /dev/null +++ b/src/com/android/tv/tuner/util/TunerInputInfoUtils.java @@ -0,0 +1,111 @@ +/* + * 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.feature.CommonFeatures; +import com.android.tv.tuner.R; +import com.android.tv.tuner.TunerHal; +import com.android.tv.tuner.tvinput.TunerTvInputService; + +/** 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 { + TvInputInfo.Builder builder = + new TvInputInfo.Builder( + context, new ComponentName(context, TunerTvInputService.class)); + return builder.setLabel(inputLabelId) + .setCanRecord(CommonFeatures.DVR.isEnabled(context)) + .setTunerCount(tunerTypeAndCount.second) + .build(); + } catch (IllegalArgumentException | NullPointerException e) { + // TunerTvInputService 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 (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/src/com/android/tv/ui/AppLayerTvView.java b/src/com/android/tv/ui/AppLayerTvView.java index b2be9f02..5454132a 100644 --- a/src/com/android/tv/ui/AppLayerTvView.java +++ b/src/com/android/tv/ui/AppLayerTvView.java @@ -21,8 +21,8 @@ import android.media.tv.TvView; import android.util.AttributeSet; import android.view.SurfaceView; import android.view.View; -import com.android.tv.common.util.CommonUtils; -import com.android.tv.common.util.Debug; +import com.android.tv.util.Debug; +import com.android.tv.util.Utils; /** * A TvView class for application layer when multiple windows are being used in the app. @@ -53,7 +53,7 @@ public class AppLayerTvView extends TvView { public void onViewAdded(View child) { if (child instanceof SurfaceView) { // Note: See b/29118070 for detail. - ((SurfaceView) child).setSecure(!CommonUtils.isDeveloper()); + ((SurfaceView) child).setSecure(!Utils.isDeveloper()); } super.onViewAdded(child); } diff --git a/src/com/android/tv/ui/ChannelBannerView.java b/src/com/android/tv/ui/ChannelBannerView.java index df487bb5..c8c3dc86 100644 --- a/src/com/android/tv/ui/ChannelBannerView.java +++ b/src/com/android/tv/ui/ChannelBannerView.java @@ -47,7 +47,7 @@ import android.widget.RelativeLayout; import android.widget.TextView; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; @@ -213,12 +213,12 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage mMainActivity, R.animator.channel_banner_program_description_fade_out); if (CommonFeatures.DVR.isEnabled(mMainActivity)) { - mDvrManager = TvSingletons.getSingletons(mMainActivity).getDvrManager(); + mDvrManager = TvApplication.getSingletons(mMainActivity).getDvrManager(); } else { mDvrManager = null; } mContentRatingsManager = - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getTvInputManagerHelper() .getContentRatingsManager(); diff --git a/src/com/android/tv/ui/KeypadChannelSwitchView.java b/src/com/android/tv/ui/KeypadChannelSwitchView.java index 5919dbf1..0c3613f6 100644 --- a/src/com/android/tv/ui/KeypadChannelSwitchView.java +++ b/src/com/android/tv/ui/KeypadChannelSwitchView.java @@ -37,12 +37,12 @@ import android.widget.ListView; import android.widget.TextView; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.Tracker; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.DurationTimer; import com.android.tv.data.Channel; import com.android.tv.data.ChannelNumber; +import com.android.tv.util.DurationTimer; import java.util.ArrayList; import java.util.List; @@ -116,7 +116,7 @@ public class KeypadChannelSwitchView extends LinearLayout super(context, attrs, defStyleAttr); mMainActivity = (MainActivity) context; - mTracker = TvSingletons.getSingletons(context).getTracker(); + mTracker = TvApplication.getSingletons(context).getTracker(); Resources resources = getResources(); mLayoutInflater = LayoutInflater.from(context); mShowDurationMillis = resources.getInteger(R.integer.keypad_channel_switch_show_duration); diff --git a/src/com/android/tv/ui/SelectInputView.java b/src/com/android/tv/ui/SelectInputView.java index 2ec498a8..aa91aa50 100644 --- a/src/com/android/tv/ui/SelectInputView.java +++ b/src/com/android/tv/ui/SelectInputView.java @@ -32,11 +32,12 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.Tracker; -import com.android.tv.common.util.DurationTimer; import com.android.tv.data.Channel; +import com.android.tv.util.DurationTimer; import com.android.tv.util.TvInputManagerHelper; import java.util.ArrayList; import java.util.Collections; @@ -143,9 +144,9 @@ public class SelectInputView extends VerticalGridView super(context, attrs, defStyleAttr); setAdapter(new InputListAdapter()); - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - mTracker = tvSingletons.getTracker(); - mTvInputManagerHelper = tvSingletons.getTvInputManagerHelper(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mTracker = appSingletons.getTracker(); + mTvInputManagerHelper = appSingletons.getTvInputManagerHelper(); mComparator = new TvInputManagerHelper.HardwareInputComparator(context, mTvInputManagerHelper); diff --git a/src/com/android/tv/ui/TunableTvView.java b/src/com/android/tv/ui/TunableTvView.java index 36de790f..97f7c65c 100644 --- a/src/com/android/tv/ui/TunableTvView.java +++ b/src/com/android/tv/ui/TunableTvView.java @@ -57,18 +57,15 @@ import android.view.SurfaceView; import android.view.View; import android.widget.FrameLayout; import android.widget.ImageView; +import com.android.tv.ApplicationSingletons; +import com.android.tv.Features; import com.android.tv.InputSessionManager; import com.android.tv.InputSessionManager.TvViewSession; import com.android.tv.R; -import com.android.tv.TvFeatures; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.Tracker; import com.android.tv.common.BuildConfig; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.util.CommonUtils; -import com.android.tv.common.util.Debug; -import com.android.tv.common.util.DurationTimer; -import com.android.tv.common.util.PermissionUtils; import com.android.tv.data.Channel; import com.android.tv.data.Program; import com.android.tv.data.ProgramDataManager; @@ -77,8 +74,11 @@ import com.android.tv.data.WatchedHistoryManager; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.parental.ParentalControlSettings; import com.android.tv.recommendation.NotificationService; +import com.android.tv.util.Debug; +import com.android.tv.util.DurationTimer; import com.android.tv.util.ImageLoader; import com.android.tv.util.NetworkUtils; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import java.lang.annotation.Retention; @@ -362,7 +362,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { getContext().startActivity(intent); } }) - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(android.R.string.no, null) .show(); } @@ -393,7 +393,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo { case TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING: case TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL: mTracker.sendChannelVideoUnavailable(mCurrentChannel, reason); - break; default: // do nothing } @@ -455,17 +454,17 @@ public class TunableTvView extends FrameLayout implements StreamInfo { super(context, attrs, defStyleAttr, defStyleRes); inflate(getContext(), R.layout.tunable_tv_view, this); - TvSingletons tvSingletons = TvSingletons.getSingletons(context); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); if (CommonFeatures.DVR.isEnabled(context)) { - mInputSessionManager = tvSingletons.getInputSessionManager(); + mInputSessionManager = appSingletons.getInputSessionManager(); } else { mInputSessionManager = null; } - mInputManager = tvSingletons.getTvInputManagerHelper(); + mInputManager = appSingletons.getTvInputManagerHelper(); mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); mCanModifyParentalControls = PermissionUtils.hasModifyParentalControls(context); - mTracker = tvSingletons.getTracker(); + mTracker = appSingletons.getTracker(); mBlockScreenType = BLOCK_SCREEN_TYPE_NORMAL; mBlockScreenView = (BlockScreenView) findViewById(R.id.block_screen); mBlockScreenView.addInfoFadeInAnimationListener( @@ -1082,7 +1081,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } private boolean closePipIfNeeded() { - if (TvFeatures.PICTURE_IN_PICTURE.isEnabled(getContext()) + if (Features.PICTURE_IN_PICTURE.isEnabled(getContext()) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && ((Activity) getContext()).isInPictureInPictureMode() && (mScreenBlocked @@ -1153,7 +1152,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } private void updateMuteStatus() { - // Workaround: BaseTunerTvInputService uses AC3 pass-through implementation, which disables + // Workaround: TunerTvInputService uses AC3 pass-through implementation, which disables // audio tracks to enforce the mute request. We don't want to send mute request if we are // not going to block the screen to prevent the video jankiness resulted by disabling audio // track before the playback is started. In other way, we should send unmute request before @@ -1184,7 +1183,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { private boolean isBundledInput() { return mInputInfo != null && mInputInfo.getType() == TvInputInfo.TYPE_TUNER - && CommonUtils.isBundledInput(mInputInfo.getId()); + && Utils.isBundledInput(mInputInfo.getId()); } /** Returns true if this view is faded out. */ diff --git a/src/com/android/tv/ui/TvOverlayManager.java b/src/com/android/tv/ui/TvOverlayManager.java index 5daa525a..58ff8c2f 100644 --- a/src/com/android/tv/ui/TvOverlayManager.java +++ b/src/com/android/tv/ui/TvOverlayManager.java @@ -32,13 +32,14 @@ import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; import android.view.ViewGroup; +import com.android.tv.ApplicationSingletons; import com.android.tv.ChannelTuner; import com.android.tv.MainActivity; import com.android.tv.MainActivity.KeyHandlerResultType; import com.android.tv.R; import com.android.tv.TimeShiftManager; +import com.android.tv.TvApplication; import com.android.tv.TvOptionsManager; -import com.android.tv.TvSingletons; import com.android.tv.analytics.Tracker; import com.android.tv.common.WeakHandler; import com.android.tv.common.feature.CommonFeatures; @@ -231,7 +232,7 @@ public class TvOverlayManager { ProgramGuideSearchFragment searchFragment) { mMainActivity = mainActivity; mChannelTuner = channelTuner; - TvSingletons singletons = TvSingletons.getSingletons(mainActivity); + ApplicationSingletons singletons = TvApplication.getSingletons(mainActivity); mChannelDataManager = singletons.getChannelDataManager(); mInputManager = singletons.getTvInputManagerHelper(); mTvView = tvView; @@ -708,10 +709,6 @@ public class TvOverlayManager { } } - public boolean isOverlayOpened() { - return mOpenedOverlays != OVERLAY_TYPE_NONE; - } - /** Hides all the opened overlays according to the flags. */ // TODO: Add test for this method. public void hideOverlays(@HideOverlayFlag int flags) { @@ -1018,10 +1015,6 @@ public class TvOverlayManager { // Do not handle media key when any pop-ups which can handle keys are active. return MainActivity.KEY_EVENT_HANDLER_RESULT_HANDLED; } - if (mTvView.isScreenBlocked()) { - // Do not handle media key when screen is blocked. - return MainActivity.KEY_EVENT_HANDLER_RESULT_HANDLED; - } TimeShiftManager timeShiftManager = mMainActivity.getTimeShiftManager(); if (!timeShiftManager.isAvailable()) { return MainActivity.KEY_EVENT_HANDLER_RESULT_HANDLED; diff --git a/src/com/android/tv/ui/TvViewUiManager.java b/src/com/android/tv/ui/TvViewUiManager.java index 7e354db3..23cd9718 100644 --- a/src/com/android/tv/ui/TvViewUiManager.java +++ b/src/com/android/tv/ui/TvViewUiManager.java @@ -42,8 +42,8 @@ import android.view.ViewGroup.LayoutParams; import android.view.ViewGroup.MarginLayoutParams; import android.view.animation.AnimationUtils; import android.widget.FrameLayout; +import com.android.tv.Features; import com.android.tv.R; -import com.android.tv.TvFeatures; import com.android.tv.TvOptionsManager; import com.android.tv.data.DisplayMode; import com.android.tv.util.TvSettings; @@ -93,7 +93,7 @@ public class TvViewUiManager { mTvView.setLayoutParams(mTvViewFrame); // Smooth PIP size change, we don't change surface size when // isInPictureInPictureMode is true. - if (!TvFeatures.PICTURE_IN_PICTURE.isEnabled(mContext) + if (!Features.PICTURE_IN_PICTURE.isEnabled(mContext) || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !((Activity) mContext).isInPictureInPictureMode())) { mTvView.setFixedSurfaceSize( diff --git a/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java b/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java index 8660c830..ad2f13f1 100644 --- a/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java +++ b/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java @@ -28,7 +28,7 @@ import android.view.ViewGroup; import android.widget.TextView; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.common.util.SharedPreferencesUtils; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.data.Channel; import com.android.tv.data.ChannelNumber; import com.android.tv.ui.OnRepeatedKeyInterceptListener; diff --git a/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java b/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java index dd42a728..766e206f 100644 --- a/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java +++ b/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java @@ -16,21 +16,11 @@ package com.android.tv.ui.sidepanel; -import android.accounts.Account; -import android.app.Activity; -import android.support.annotation.NonNull; -import android.util.Log; -import android.widget.Toast; - - - - import com.android.tv.R; -import com.android.tv.TvSingletons; -import com.android.tv.common.CommonPreferences; +import com.android.tv.TvApplication; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.util.CommonUtils; - +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.List; @@ -61,7 +51,7 @@ public class DeveloperOptionFragment extends SideFragment { } }); } - if (CommonUtils.isDeveloper()) { + if (Utils.isDeveloper()) { items.add( new ActionItem(getString(R.string.dev_item_watch_history)) { @Override @@ -78,21 +68,21 @@ public class DeveloperOptionFragment extends SideFragment { @Override protected void onUpdate() { super.onUpdate(); - setChecked(CommonPreferences.getStoreTsStream(getContext())); + setChecked(TunerPreferences.getStoreTsStream(getContext())); } @Override protected void onSelected() { super.onSelected(); - CommonPreferences.setStoreTsStream(getContext(), isChecked()); + TunerPreferences.setStoreTsStream(getContext(), isChecked()); } }); - if (CommonUtils.isDeveloper()) { + if (Utils.isDeveloper()) { items.add( new ActionItem(getString(R.string.dev_item_show_performance_monitor_log)) { @Override protected void onSelected() { - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getPerformanceMonitor() .startPerformanceMonitorEventDebugActivity(getContext()); } diff --git a/src/com/android/tv/ui/sidepanel/SettingsFragment.java b/src/com/android/tv/ui/sidepanel/SettingsFragment.java index 31d00fa6..2552b5dd 100644 --- a/src/com/android/tv/ui/sidepanel/SettingsFragment.java +++ b/src/com/android/tv/ui/sidepanel/SettingsFragment.java @@ -16,7 +16,7 @@ package com.android.tv.ui.sidepanel; -import static com.android.tv.TvFeatures.TUNER; +import static com.android.tv.Features.TUNER; import android.app.ApplicationErrorReport; import android.content.Intent; @@ -26,13 +26,13 @@ import android.widget.Toast; import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.TvSingletons; -import com.android.tv.common.CommonPreferences; -import com.android.tv.common.customization.CustomizationManager; -import com.android.tv.common.util.PermissionUtils; +import com.android.tv.customization.TvCustomizationManager; import com.android.tv.dialog.PinDialogFragment; import com.android.tv.license.LicenseSideFragment; import com.android.tv.license.Licenses; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.util.PermissionUtils; +import com.android.tv.util.SetupUtils; import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.List; @@ -82,9 +82,7 @@ public class SettingsFragment extends SideFragment { items.add(customizeChannelListItem); final MainActivity activity = getMainActivity(); boolean hasNewInput = - TvSingletons.getSingletons(getContext()) - .getSetupUtils() - .hasNewInput(activity.getTvInputManagerHelper()); + SetupUtils.getInstance(activity).hasNewInput(activity.getTvInputManagerHelper()); items.add( new ActionItem( getString(R.string.settings_channel_source_item_setup), @@ -129,7 +127,7 @@ public class SettingsFragment extends SideFragment { boolean showTrickplaySetting = false; if (TUNER.isEnabled(getContext())) { for (TvInputInfo inputInfo : - TvSingletons.getSingletons(getContext()) + TvApplication.getSingletons(getContext()) .getTvInputManagerHelper() .getTvInputInfos(true, true)) { if (Utils.isInternalTvInput(getContext(), inputInfo.getId())) { @@ -139,8 +137,8 @@ public class SettingsFragment extends SideFragment { } if (showTrickplaySetting) { showTrickplaySetting = - CustomizationManager.getTrickplayMode(getContext()) - == CustomizationManager.TRICKPLAY_MODE_ENABLED; + TvCustomizationManager.getTrickplayMode(getContext()) + == TvCustomizationManager.TRICKPLAY_MODE_ENABLED; } } if (showTrickplaySetting) { @@ -154,20 +152,20 @@ public class SettingsFragment extends SideFragment { protected void onUpdate() { super.onUpdate(); boolean enabled = - CommonPreferences.getTrickplaySetting(getContext()) - != CommonPreferences.TRICKPLAY_SETTING_DISABLED; + TunerPreferences.getTrickplaySetting(getContext()) + != TunerPreferences.TRICKPLAY_SETTING_DISABLED; setChecked(enabled); } @Override protected void onSelected() { super.onSelected(); - @CommonPreferences.TrickplaySetting + @TunerPreferences.TrickplaySetting int setting = isChecked() - ? CommonPreferences.TRICKPLAY_SETTING_ENABLED - : CommonPreferences.TRICKPLAY_SETTING_DISABLED; - CommonPreferences.setTrickplaySetting(getContext(), setting); + ? TunerPreferences.TRICKPLAY_SETTING_ENABLED + : TunerPreferences.TRICKPLAY_SETTING_DISABLED; + TunerPreferences.setTrickplaySetting(getContext(), setting); } }); } diff --git a/src/com/android/tv/ui/sidepanel/SideFragment.java b/src/com/android/tv/ui/sidepanel/SideFragment.java index 2902ea7f..0660a6f9 100644 --- a/src/com/android/tv/ui/sidepanel/SideFragment.java +++ b/src/com/android/tv/ui/sidepanel/SideFragment.java @@ -30,13 +30,13 @@ import android.widget.FrameLayout; import android.widget.TextView; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; import com.android.tv.analytics.HasTrackerLabel; import com.android.tv.analytics.Tracker; -import com.android.tv.common.util.DurationTimer; -import com.android.tv.common.util.SystemProperties; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.ProgramDataManager; +import com.android.tv.util.DurationTimer; +import com.android.tv.util.SystemProperties; import com.android.tv.util.ViewCache; import java.util.List; @@ -85,7 +85,7 @@ public abstract class SideFragment<T extends Item> extends Fragment implements H super.onAttach(context); mChannelDataManager = getMainActivity().getChannelDataManager(); mProgramDataManager = getMainActivity().getProgramDataManager(); - mTracker = TvSingletons.getSingletons(context).getTracker(); + mTracker = TvApplication.getSingletons(context).getTracker(); } @Override diff --git a/src/com/android/tv/ui/sidepanel/parentalcontrols/ChannelsBlockedFragment.java b/src/com/android/tv/ui/sidepanel/parentalcontrols/ChannelsBlockedFragment.java index d70cf97a..f0396bfe 100644 --- a/src/com/android/tv/ui/sidepanel/parentalcontrols/ChannelsBlockedFragment.java +++ b/src/com/android/tv/ui/sidepanel/parentalcontrols/ChannelsBlockedFragment.java @@ -19,8 +19,6 @@ package com.android.tv.ui.sidepanel.parentalcontrols; import android.database.ContentObserver; import android.media.tv.TvContract; import android.net.Uri; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.os.Handler; import android.support.v17.leanback.widget.VerticalGridView; @@ -32,7 +30,6 @@ import android.widget.TextView; import com.android.tv.R; import com.android.tv.data.Channel; import com.android.tv.data.ChannelNumber; -import com.android.tv.recommendation.ChannelPreviewUpdater; import com.android.tv.ui.OnRepeatedKeyInterceptListener; import com.android.tv.ui.sidepanel.ActionItem; import com.android.tv.ui.sidepanel.ChannelCheckItem; @@ -50,7 +47,6 @@ public class ChannelsBlockedFragment extends SideFragment { private final List<Channel> mChannels = new ArrayList<>(); private long mLastFocusedChannelId = Channel.INVALID_ID; private int mSelectedPosition = INVALID_POSITION; - private boolean mUpdated; private final ContentObserver mProgramUpdateObserver = new ContentObserver(new Handler()) { @Override @@ -98,7 +94,6 @@ public class ChannelsBlockedFragment extends SideFragment { .registerContentObserver( TvContract.Programs.CONTENT_URI, true, mProgramUpdateObserver); getMainActivity().startShrunkenTvView(true, true); - mUpdated = false; return view; } @@ -107,10 +102,6 @@ public class ChannelsBlockedFragment extends SideFragment { getActivity().getContentResolver().unregisterContentObserver(mProgramUpdateObserver); getChannelDataManager().applyUpdatedValuesToDb(); getMainActivity().endShrunkenTvView(); - if (VERSION.SDK_INT >= VERSION_CODES.O && mUpdated) { - ChannelPreviewUpdater.getInstance(getMainActivity()) - .updatePreviewDataForChannelsImmediately(); - } super.onDestroyView(); } @@ -194,7 +185,6 @@ public class ChannelsBlockedFragment extends SideFragment { } mBlockedChannelCount = lock ? mChannels.size() : 0; notifyItemsChanged(); - mUpdated = true; } @Override @@ -238,7 +228,6 @@ public class ChannelsBlockedFragment extends SideFragment { getChannelDataManager().updateLocked(getChannel().getId(), isChecked()); mBlockedChannelCount += isChecked() ? 1 : -1; notifyItemChanged(mLockAllItem); - mUpdated = true; } @Override diff --git a/src/com/android/tv/ui/sidepanel/parentalcontrols/RatingsFragment.java b/src/com/android/tv/ui/sidepanel/parentalcontrols/RatingsFragment.java index 128fcd1a..882843c2 100644 --- a/src/com/android/tv/ui/sidepanel/parentalcontrols/RatingsFragment.java +++ b/src/com/android/tv/ui/sidepanel/parentalcontrols/RatingsFragment.java @@ -26,8 +26,8 @@ import android.widget.CompoundButton; import android.widget.ImageView; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.common.experiments.Experiments; import com.android.tv.dialog.WebDialogFragment; +import com.android.tv.experiments.Experiments; import com.android.tv.license.LicenseUtils; import com.android.tv.parental.ContentRatingSystem; import com.android.tv.parental.ContentRatingSystem.Rating; diff --git a/src/com/android/tv/util/account/AccountHelperImpl.java b/src/com/android/tv/util/AccountHelper.java index 58fbd27e..a3e6ad58 100644 --- a/src/com/android/tv/util/account/AccountHelperImpl.java +++ b/src/com/android/tv/util/AccountHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 The Android Open Source Project + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.util.account; +package com.android.tv.util; import android.accounts.Account; import android.content.Context; @@ -23,23 +23,24 @@ import android.preference.PreferenceManager; import android.support.annotation.Nullable; /** Helper methods for getting and selecting a user account. */ -public class AccountHelperImpl implements com.android.tv.util.account.AccountHelper { +public class AccountHelper { + private static final String TAG = "AccountHelper"; + private static final boolean DEBUG = false; private static final String SELECTED_ACCOUNT = "android.tv.livechannels.selected_account"; - protected final Context mContext; + private final Context mContext; private final SharedPreferences mDefaultPreferences; @Nullable private Account mSelectedAccount; - public AccountHelperImpl(Context context) { + public AccountHelper(Context context) { mContext = context.getApplicationContext(); mDefaultPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); } /** Returns the currently selected account or {@code null} if none is selected. */ - @Override @Nullable - public final Account getSelectedAccount() { + public Account getSelectedAccount() { String accountId = mDefaultPreferences.getString(SELECTED_ACCOUNT, null); if (accountId == null) { return null; @@ -56,12 +57,8 @@ public class AccountHelperImpl implements com.android.tv.util.account.AccountHel return mSelectedAccount; } - /** - * Returns all eligible accounts. - * - * <p>Override this method to return the accounts needed. - */ - protected Account[] getEligibleAccounts() { + /** Returns all eligible accounts . */ + private Account[] getEligibleAccounts() { return new Account[0]; } @@ -70,9 +67,8 @@ public class AccountHelperImpl implements com.android.tv.util.account.AccountHel * * @return selected account or {@code null} if none is selected. */ - @Override @Nullable - public final Account selectFirstAccount() { + public Account selectFirstAccount() { Account account = getFirstEligibleAccount(); if (account != null) { selectAccount(account); @@ -85,9 +81,8 @@ public class AccountHelperImpl implements com.android.tv.util.account.AccountHel * * @return first account or {@code null} if none is eligible. */ - @Override @Nullable - public final Account getFirstEligibleAccount() { + public Account getFirstEligibleAccount() { Account[] accounts = getEligibleAccounts(); return accounts.length == 0 ? null : accounts[0]; } diff --git a/src/com/android/tv/util/AsyncDbTask.java b/src/com/android/tv/util/AsyncDbTask.java index b575df53..376fcc70 100644 --- a/src/com/android/tv/util/AsyncDbTask.java +++ b/src/com/android/tv/util/AsyncDbTask.java @@ -28,7 +28,6 @@ import android.support.annotation.WorkerThread; import android.util.Log; import android.util.Range; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.concurrent.NamedThreadFactory; import com.android.tv.data.Channel; import com.android.tv.data.Program; import com.android.tv.dvr.data.RecordedProgram; @@ -48,7 +47,6 @@ import java.util.concurrent.RejectedExecutionException; * @param <Progress> the type of the progress units published during the background computation. * @param <Result> the type of the result of the background computation. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public abstract class AsyncDbTask<Params, Progress, Result> extends AsyncTask<Params, Progress, Result> { private static final String TAG = "AsyncDbTask"; @@ -151,7 +149,7 @@ public abstract class AsyncDbTask<Params, Progress, Result> return null; } } catch (Exception e) { - SoftPreconditions.warn(TAG, null, e, "Error querying " + this); + SoftPreconditions.warn(TAG, null, "Error querying " + this, e); return null; } } diff --git a/src/com/android/tv/util/BitmapUtils.java b/src/com/android/tv/util/BitmapUtils.java index 4c67d934..6902a6fe 100644 --- a/src/com/android/tv/util/BitmapUtils.java +++ b/src/com/android/tv/util/BitmapUtils.java @@ -29,7 +29,6 @@ import android.net.Uri; import android.support.annotation.NonNull; import android.text.TextUtils; import android.util.Log; -import com.android.tv.common.util.NetworkTrafficTags; import java.io.BufferedInputStream; import java.io.Closeable; import java.io.IOException; diff --git a/src/com/android/tv/util/Clock.java b/src/com/android/tv/util/Clock.java new file mode 100644 index 00000000..0004a669 --- /dev/null +++ b/src/com/android/tv/util/Clock.java @@ -0,0 +1,64 @@ +/* + * 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.util; + +import android.os.SystemClock; + +/** + * An interface through which system clocks can be read. The {@link #SYSTEM} implementation must be + * used for all non-test cases. + */ +public interface Clock { + /** + * Returns the current time in milliseconds since January 1, 1970 00:00:00.0 UTC. See {@link + * System#currentTimeMillis()}. + */ + long currentTimeMillis(); + + /** + * Returns milliseconds since boot, including time spent in sleep. + * + * @see SystemClock#elapsedRealtime() + */ + long elapsedRealtime(); + + /** + * Waits a given number of milliseconds (of uptimeMillis) before returning. + * + * @param ms to sleep before returning, in milliseconds of uptime. + * @see SystemClock#sleep(long) + */ + void sleep(long ms); + + /** The default implementation of Clock. */ + Clock SYSTEM = + new Clock() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public long elapsedRealtime() { + return SystemClock.elapsedRealtime(); + } + + @Override + public void sleep(long ms) { + SystemClock.sleep(ms); + } + }; +} diff --git a/src/com/android/tv/util/Debug.java b/src/com/android/tv/util/Debug.java new file mode 100644 index 00000000..422a61e3 --- /dev/null +++ b/src/com/android/tv/util/Debug.java @@ -0,0 +1,50 @@ +/* + * 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.util; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** A class only for help developers. */ +public class Debug { + /** + * A threshold of start up time, when the start up time of Live TV is more than it, a warning + * will show to the developer. + */ + public static final long TIME_START_UP_DURATION_THRESHOLD = TimeUnit.SECONDS.toMillis(6); + /** Tag for measuring start up time of Live TV. */ + public static final String TAG_START_UP_TIMER = "start_up_timer"; + + /** A global map for duration timers. */ + private static final Map<String, DurationTimer> sTimerMap = new HashMap<>(); + + /** Returns the global duration timer by tag. */ + public static DurationTimer getTimer(String tag) { + if (sTimerMap.get(tag) != null) { + return sTimerMap.get(tag); + } + DurationTimer timer = new DurationTimer(tag, true); + sTimerMap.put(tag, timer); + return timer; + } + + /** Removes the global duration timer by tag. */ + public static DurationTimer removeTimer(String tag) { + return sTimerMap.remove(tag); + } +} diff --git a/src/com/android/tv/util/DurationTimer.java b/src/com/android/tv/util/DurationTimer.java new file mode 100644 index 00000000..6aabf37b --- /dev/null +++ b/src/com/android/tv/util/DurationTimer.java @@ -0,0 +1,80 @@ +/* + * 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.util; + +import android.os.SystemClock; +import android.util.Log; +import com.android.tv.common.BuildConfig; + +/** Times a duration. */ +public final class DurationTimer { + private static final String TAG = "DurationTimer"; + public static final long TIME_NOT_SET = -1; + + private long mStartTimeMs = TIME_NOT_SET; + private String mTag = TAG; + private boolean mLogEngOnly; + + public DurationTimer() {} + + public DurationTimer(String tag, boolean logEngOnly) { + mTag = tag; + mLogEngOnly = logEngOnly; + } + + /** Returns true if the timer is running. */ + public boolean isRunning() { + return mStartTimeMs != TIME_NOT_SET; + } + + /** Start the timer. */ + public void start() { + mStartTimeMs = SystemClock.elapsedRealtime(); + } + + /** Returns true if timer is started. */ + public boolean isStarted() { + return mStartTimeMs != TIME_NOT_SET; + } + + /** + * Returns the current duration in milliseconds or {@link #TIME_NOT_SET} if the timer is not + * running. + */ + public long getDuration() { + return isRunning() ? SystemClock.elapsedRealtime() - mStartTimeMs : TIME_NOT_SET; + } + + /** + * Stops the timer and resets its value to {@link #TIME_NOT_SET}. + * + * @return the current duration in milliseconds or {@link #TIME_NOT_SET} if the timer is not + * running. + */ + public long reset() { + long duration = getDuration(); + mStartTimeMs = TIME_NOT_SET; + return duration; + } + + /** Adds information and duration time to the log. */ + public void log(String message) { + if (isRunning() && (!mLogEngOnly || BuildConfig.ENG)) { + Log.i(mTag, message + " : " + getDuration() + "ms"); + } + } +} diff --git a/src/com/android/tv/util/ImageLoader.java b/src/com/android/tv/util/ImageLoader.java index 32ac89f0..9b4d2a70 100644 --- a/src/com/android/tv/util/ImageLoader.java +++ b/src/com/android/tv/util/ImageLoader.java @@ -31,7 +31,6 @@ import android.support.annotation.WorkerThread; import android.util.ArraySet; import android.util.Log; import com.android.tv.R; -import com.android.tv.common.concurrent.NamedThreadFactory; import com.android.tv.util.BitmapUtils.ScaledBitmapInfo; import java.lang.ref.WeakReference; import java.util.HashMap; diff --git a/src/com/android/tv/util/LocationUtils.java b/src/com/android/tv/util/LocationUtils.java new file mode 100644 index 00000000..a960c616 --- /dev/null +++ b/src/com/android/tv/util/LocationUtils.java @@ -0,0 +1,137 @@ +/* + * 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.util; + +import android.content.Context; +import android.location.Address; +import android.location.Geocoder; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.Log; +import com.android.tv.tuner.util.PostalCodeUtils; +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +/** A utility class to get the current location. */ +public class LocationUtils { + private static final String TAG = "LocationUtils"; + private static final boolean DEBUG = false; + + private static Context sApplicationContext; + private static Address sAddress; + private static String sCountry; + private static IOException sError; + + /** Checks the current location. */ + public static synchronized Address getCurrentAddress(Context context) + throws IOException, SecurityException { + if (sAddress != null) { + return sAddress; + } + if (sError != null) { + throw sError; + } + if (sApplicationContext == null) { + sApplicationContext = context.getApplicationContext(); + } + LocationUtilsHelper.startLocationUpdates(); + return null; + } + + /** Returns the current country. */ + @NonNull + public static synchronized String getCurrentCountry(Context context) { + if (sCountry != null) { + return sCountry; + } + if (TextUtils.isEmpty(sCountry)) { + sCountry = context.getResources().getConfiguration().locale.getCountry(); + } + return sCountry; + } + + private static void updateAddress(Location location) { + if (DEBUG) Log.d(TAG, "Updating address with " + location); + if (location == null) { + return; + } + Geocoder geocoder = new Geocoder(sApplicationContext, Locale.getDefault()); + try { + List<Address> addresses = + geocoder.getFromLocation(location.getLatitude(), location.getLongitude(), 1); + if (addresses != null && !addresses.isEmpty()) { + sAddress = addresses.get(0); + if (DEBUG) Log.d(TAG, "Got " + sAddress); + try { + PostalCodeUtils.updatePostalCode(sApplicationContext); + } catch (Exception e) { + // Do nothing + } + } else { + if (DEBUG) Log.d(TAG, "No address returned"); + } + sError = null; + } catch (IOException e) { + Log.w(TAG, "Error in updating address", e); + sError = e; + } + } + + private LocationUtils() {} + + private static class LocationUtilsHelper { + private static final LocationListener LOCATION_LISTENER = + new LocationListener() { + @Override + public void onLocationChanged(Location location) { + updateAddress(location); + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) {} + + @Override + public void onProviderEnabled(String provider) {} + + @Override + public void onProviderDisabled(String provider) {} + }; + + private static LocationManager sLocationManager; + + public static void startLocationUpdates() { + if (sLocationManager == null) { + sLocationManager = + (LocationManager) + sApplicationContext.getSystemService(Context.LOCATION_SERVICE); + try { + sLocationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, 1000, 10, LOCATION_LISTENER, null); + } catch (SecurityException e) { + // Enables requesting the location updates again. + sLocationManager = null; + throw e; + } + } + } + } +} diff --git a/src/com/android/tv/util/NamedThreadFactory.java b/src/com/android/tv/util/NamedThreadFactory.java new file mode 100644 index 00000000..264b8b3f --- /dev/null +++ b/src/com/android/tv/util/NamedThreadFactory.java @@ -0,0 +1,45 @@ +/* + * 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.util; + +import android.support.annotation.NonNull; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** A thread factory that creates threads with a suffix. */ +public class NamedThreadFactory implements ThreadFactory { + private final AtomicInteger mCount = new AtomicInteger(0); + private final ThreadFactory mDefaultThreadFactory; + private final String mPrefix; + + public NamedThreadFactory(final String baseName) { + mDefaultThreadFactory = Executors.defaultThreadFactory(); + mPrefix = baseName + "-"; + } + + @Override + public Thread newThread(@NonNull final Runnable runnable) { + final Thread thread = mDefaultThreadFactory.newThread(runnable); + thread.setName(mPrefix + mCount.getAndIncrement()); + return thread; + } + + public boolean namedWithPrefix(Thread thread) { + return thread.getName().startsWith(mPrefix); + } +} diff --git a/src/com/android/tv/util/NetworkTrafficTags.java b/src/com/android/tv/util/NetworkTrafficTags.java new file mode 100644 index 00000000..85ecde5b --- /dev/null +++ b/src/com/android/tv/util/NetworkTrafficTags.java @@ -0,0 +1,63 @@ +/* + * 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.util; + +import android.net.TrafficStats; +import android.support.annotation.NonNull; +import java.util.concurrent.Executor; + +/** Constants for tagging network traffic in the Live channels app. */ +public final class NetworkTrafficTags { + + public static final int DEFAULT_LIVE_CHANNELS = 1; + public static final int LOGO_FETCHER = 2; + public static final int HDHOMERUN = 3; + public static final int EPG_FETCH = 4; + + /** + * An executor which simply wraps a provided delegate executor, but calls {@link + * TrafficStats#setThreadStatsTag(int)} before executing any task. + */ + public static class TrafficStatsTaggingExecutor implements Executor { + private final Executor delegateExecutor; + private final int tag; + + public TrafficStatsTaggingExecutor(Executor delegateExecutor, int tag) { + this.delegateExecutor = delegateExecutor; + this.tag = tag; + } + + @Override + public void execute(final @NonNull Runnable command) { + // TODO(b/62038127): robolectric does not support lamdas in unbundled apps + delegateExecutor.execute( + new Runnable() { + @Override + public void run() { + TrafficStats.setThreadStatsTag(tag); + try { + command.run(); + } finally { + TrafficStats.clearThreadStatsTag(); + } + } + }); + } + } + + private NetworkTrafficTags() {} +} diff --git a/src/com/android/tv/util/OnboardingUtils.java b/src/com/android/tv/util/OnboardingUtils.java index 63383aab..3b72e091 100644 --- a/src/com/android/tv/util/OnboardingUtils.java +++ b/src/com/android/tv/util/OnboardingUtils.java @@ -48,8 +48,8 @@ public final class OnboardingUtils { } /** - * Checks if this is the first run of {@link com.android.tv.MainActivity} with the - * current onboarding version. + * Checks if this is the first run of {@link com.android.tv.MainActivity} with the current + * onboarding version. */ public static boolean isFirstRunWithCurrentVersion(Context context) { int versionCode = @@ -59,8 +59,8 @@ public final class OnboardingUtils { } /** - * Marks that the first run of {@link com.android.tv.MainActivity} with the current - * onboarding version has been completed. + * Marks that the first run of {@link com.android.tv.MainActivity} with the current onboarding + * version has been completed. */ public static void setFirstRunWithCurrentVersionCompleted(Context context) { PreferenceManager.getDefaultSharedPreferences(context) diff --git a/src/com/android/tv/util/PermissionUtils.java b/src/com/android/tv/util/PermissionUtils.java new file mode 100644 index 00000000..b3e4e3a2 --- /dev/null +++ b/src/com/android/tv/util/PermissionUtils.java @@ -0,0 +1,53 @@ +package com.android.tv.util; + +import android.content.Context; +import android.content.pm.PackageManager; + +/** Util class to handle permissions. */ +public class PermissionUtils { + /** Permission to read the TV listings. */ + public static final String PERMISSION_READ_TV_LISTINGS = "android.permission.READ_TV_LISTINGS"; + + private static Boolean sHasAccessAllEpgPermission; + private static Boolean sHasAccessWatchedHistoryPermission; + private static Boolean sHasModifyParentalControlsPermission; + + public static boolean hasAccessAllEpg(Context context) { + if (sHasAccessAllEpgPermission == null) { + sHasAccessAllEpgPermission = + context.checkSelfPermission( + "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA") + == PackageManager.PERMISSION_GRANTED; + } + return sHasAccessAllEpgPermission; + } + + public static boolean hasAccessWatchedHistory(Context context) { + if (sHasAccessWatchedHistoryPermission == null) { + sHasAccessWatchedHistoryPermission = + context.checkSelfPermission( + "com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS") + == PackageManager.PERMISSION_GRANTED; + } + return sHasAccessWatchedHistoryPermission; + } + + public static boolean hasModifyParentalControls(Context context) { + if (sHasModifyParentalControlsPermission == null) { + sHasModifyParentalControlsPermission = + context.checkSelfPermission("android.permission.MODIFY_PARENTAL_CONTROLS") + == PackageManager.PERMISSION_GRANTED; + } + return sHasModifyParentalControlsPermission; + } + + public static boolean hasReadTvListings(Context context) { + return context.checkSelfPermission(PERMISSION_READ_TV_LISTINGS) + == PackageManager.PERMISSION_GRANTED; + } + + public static boolean hasInternet(Context context) { + return context.checkSelfPermission("android.permission.INTERNET") + == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/src/com/android/tv/util/RecurringRunner.java b/src/com/android/tv/util/RecurringRunner.java index 764689c2..c1b724a2 100644 --- a/src/com/android/tv/util/RecurringRunner.java +++ b/src/com/android/tv/util/RecurringRunner.java @@ -22,8 +22,8 @@ import android.os.AsyncTask; import android.os.Handler; import android.support.annotation.WorkerThread; import android.util.Log; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.SharedPreferencesUtils; import java.util.Date; /** diff --git a/src/com/android/tv/util/SetupUtils.java b/src/com/android/tv/util/SetupUtils.java index ad5d5024..a1ff192b 100644 --- a/src/com/android/tv/util/SetupUtils.java +++ b/src/com/android/tv/util/SetupUtils.java @@ -28,15 +28,15 @@ import android.media.tv.TvInputManager; import android.preference.PreferenceManager; import android.support.annotation.Nullable; import android.support.annotation.UiThread; -import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.ArraySet; import android.util.Log; -import com.android.tv.TvSingletons; -import com.android.tv.common.BaseApplication; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; +import com.android.tv.tuner.tvinput.TunerTvInputService; import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -54,8 +54,9 @@ public class SetupUtils { // Recognized inputs means that the user already knows the inputs are installed. private static final String PREF_KEY_RECOGNIZED_INPUTS = "recognized_inputs"; private static final String PREF_KEY_IS_FIRST_TUNE = "is_first_tune"; + private static SetupUtils sSetupUtils; - private final Context mContext; + private final TvApplication mTvApplication; private final SharedPreferences mSharedPreferences; private final Set<String> mKnownInputs; private final Set<String> mSetUpInputs; @@ -63,10 +64,9 @@ public class SetupUtils { private boolean mIsFirstTune; private final String mTunerInputId; - @VisibleForTesting - protected SetupUtils(Context context) { - mContext = context; - mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + private SetupUtils(TvApplication tvApplication) { + mTvApplication = tvApplication; + mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(tvApplication); mSetUpInputs = new ArraySet<>(); mSetUpInputs.addAll( mSharedPreferences.getStringSet(PREF_KEY_SET_UP_INPUTS, Collections.emptySet())); @@ -77,16 +77,18 @@ public class SetupUtils { mRecognizedInputs.addAll( mSharedPreferences.getStringSet(PREF_KEY_RECOGNIZED_INPUTS, mKnownInputs)); mIsFirstTune = mSharedPreferences.getBoolean(PREF_KEY_IS_FIRST_TUNE, true); - mTunerInputId = BaseApplication.getSingletons(context).getEmbeddedTunerInputId(); + mTunerInputId = + TvContract.buildInputId( + new ComponentName(tvApplication, TunerTvInputService.class)); } - /** - * Creates an instance of {@link SetupUtils}. - * - * <p><b>WARNING</b> this should only be called by the top level application. - */ - public static SetupUtils createForTvSingletons(Context context) { - return new SetupUtils(context.getApplicationContext()); + /** Gets an instance of {@link SetupUtils}. */ + public static SetupUtils getInstance(Context context) { + if (sSetupUtils != null) { + return sSetupUtils; + } + sSetupUtils = new SetupUtils((TvApplication) context.getApplicationContext()); + return sSetupUtils; } /** Additional work after the setup of TV input. */ @@ -97,15 +99,14 @@ public class SetupUtils { // which one is the last callback. To reduce error prune, we update channel // list again and make all channels of {@code inputId} browsable. onSetupDone(inputId); - final ChannelDataManager manager = - TvSingletons.getSingletons(mContext).getChannelDataManager(); + final ChannelDataManager manager = mTvApplication.getChannelDataManager(); if (!manager.isDbLoadFinished()) { manager.addListener( new ChannelDataManager.Listener() { @Override public void onLoadFinished() { manager.removeListener(this); - updateChannelsAfterSetup(mContext, inputId, postRunnable); + updateChannelsAfterSetup(mTvApplication, inputId, postRunnable); } @Override @@ -115,14 +116,14 @@ public class SetupUtils { public void onChannelBrowsableChanged() {} }); } else { - updateChannelsAfterSetup(mContext, inputId, postRunnable); + updateChannelsAfterSetup(mTvApplication, inputId, postRunnable); } } private static void updateChannelsAfterSetup( Context context, final String inputId, final Runnable postRunnable) { - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - final ChannelDataManager manager = tvSingletons.getChannelDataManager(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + final ChannelDataManager manager = appSingletons.getChannelDataManager(); manager.updateChannels( new Runnable() { @Override @@ -158,9 +159,8 @@ public class SetupUtils { @UiThread public void markNewChannelsBrowsable() { Set<String> newInputsWithChannels = new HashSet<>(); - TvSingletons singletons = TvSingletons.getSingletons(mContext); - TvInputManagerHelper tvInputManagerHelper = singletons.getTvInputManagerHelper(); - ChannelDataManager channelDataManager = singletons.getChannelDataManager(); + TvInputManagerHelper tvInputManagerHelper = mTvApplication.getTvInputManagerHelper(); + ChannelDataManager channelDataManager = mTvApplication.getChannelDataManager(); SoftPreconditions.checkState(channelDataManager.isDbLoadFinished()); for (TvInputInfo input : tvInputManagerHelper.getTvInputInfos(true, true)) { String inputId = input.getId(); @@ -340,7 +340,8 @@ public class SetupUtils { try { // Just after booting, input list from TvInputManager are not reliable. // So we need to double-check package existence. b/29034900 - mContext.getPackageManager() + mTvApplication + .getPackageManager() .getPackageInfo( ComponentName.unflattenFromString(input).getPackageName(), PackageManager.GET_ACTIVITIES); diff --git a/src/com/android/tv/util/SqlParams.java b/src/com/android/tv/util/SqlParams.java deleted file mode 100644 index c4b803b6..00000000 --- a/src/com/android/tv/util/SqlParams.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.util; - -import android.database.DatabaseUtils; -import java.util.Arrays; - -/** Convenience class for SQL operations. */ -public class SqlParams { - private String mTables; - private String mSelection; - private String[] mSelectionArgs; - - public SqlParams(String tables, String selection, String... selectionArgs) { - setTables(tables); - setWhere(selection, selectionArgs); - } - - public String getTables() { - return mTables; - } - - public String getSelection() { - return mSelection; - } - - public String[] getSelectionArgs() { - return mSelectionArgs; - } - - public void setTables(String tables) { - mTables = tables; - } - - public void setWhere(String selection, String... selectionArgs) { - mSelection = selection; - mSelectionArgs = selectionArgs; - } - - public void appendWhere(String selection, String... selectionArgs) { - mSelection = DatabaseUtils.concatenateWhere(mSelection, selection); - if (selectionArgs != null) { - mSelectionArgs = DatabaseUtils.appendSelectionArgs(mSelectionArgs, selectionArgs); - } - } - - public void appendWhereEquals(String name, String value) { - appendWhere(name + "=?", value); - } - - @Override - public String toString() { - return "tables " - + getTables() - + " where " - + getSelection() - + " with " - + Arrays.toString(getSelectionArgs()); - } -} diff --git a/src/com/android/tv/util/StringUtils.java b/src/com/android/tv/util/StringUtils.java new file mode 100644 index 00000000..eeaf33a6 --- /dev/null +++ b/src/com/android/tv/util/StringUtils.java @@ -0,0 +1,34 @@ +/* + * 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.util; + +/** Utility class for handling {@link String}. */ +public final class StringUtils { + + private StringUtils() {} + + /** Returns compares two strings lexicographically and handles null values quietly. */ + public static int compare(String a, String b) { + if (a == null) { + return b == null ? 0 : -1; + } + if (b == null) { + return 1; + } + return a.compareTo(b); + } +} diff --git a/src/com/android/tv/util/SystemProperties.java b/src/com/android/tv/util/SystemProperties.java new file mode 100644 index 00000000..e1b8a398 --- /dev/null +++ b/src/com/android/tv/util/SystemProperties.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.util; + +import com.android.tv.common.BooleanSystemProperty; + +/** A convenience class for getting TV related system properties. */ +public final class SystemProperties { + + /** Allow Google Analytics for eng builds. */ + public static final BooleanSystemProperty ALLOW_ANALYTICS_IN_ENG = + new BooleanSystemProperty("tv_allow_analytics_in_eng", false); + + /** Allow Strict mode for debug builds. */ + public static final BooleanSystemProperty ALLOW_STRICT_MODE = + new BooleanSystemProperty("tv_allow_strict_mode", true); + + /** When true {@link android.view.KeyEvent}s are logged. Defaults to false. */ + public static final BooleanSystemProperty LOG_KEYEVENT = + new BooleanSystemProperty("tv_log_keyevent", false); + /** When true debug keys are used. Defaults to false. */ + public static final BooleanSystemProperty USE_DEBUG_KEYS = + new BooleanSystemProperty("tv_use_debug_keys", false); + + /** Send {@link com.android.tv.analytics.Tracker} information. Defaults to {@code true}. */ + public static final BooleanSystemProperty USE_TRACKER = + new BooleanSystemProperty("tv_use_tracker", true); + + static { + updateSystemProperties(); + } + + private SystemProperties() {} + + /** Update the TV related system properties. */ + public static void updateSystemProperties() { + BooleanSystemProperty.resetAll(); + } +} diff --git a/src/com/android/tv/util/TvInputManagerHelper.java b/src/com/android/tv/util/TvInputManagerHelper.java index c4feafb7..e97bc4f9 100644 --- a/src/com/android/tv/util/TvInputManagerHelper.java +++ b/src/com/android/tv/util/TvInputManagerHelper.java @@ -21,19 +21,17 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.hardware.hdmi.HdmiDeviceInfo; -import android.media.tv.TvContentRatingSystemInfo; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; import android.media.tv.TvInputManager.TvInputCallback; import android.os.Handler; -import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.ArrayMap; import android.util.Log; -import com.android.tv.TvFeatures; +import com.android.tv.Features; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.util.CommonUtils; +import com.android.tv.common.TvCommonUtils; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.parental.ParentalControlSettings; import java.util.ArrayList; @@ -49,58 +47,6 @@ public class TvInputManagerHelper { private static final String TAG = "TvInputManagerHelper"; private static final boolean DEBUG = false; - public interface TvInputManagerInterface { - TvInputInfo getTvInputInfo(String inputId); - - Integer getInputState(String inputId); - - void registerCallback(TvInputCallback internalCallback, Handler handler); - - void unregisterCallback(TvInputCallback internalCallback); - - List<TvInputInfo> getTvInputList(); - - List<TvContentRatingSystemInfo> getTvContentRatingSystemList(); - } - - private static final class TvInputManagerImpl implements TvInputManagerInterface { - private final TvInputManager delegate; - - private TvInputManagerImpl(TvInputManager delegate) { - this.delegate = delegate; - } - - @Override - public TvInputInfo getTvInputInfo(String inputId) { - return delegate.getTvInputInfo(inputId); - } - - @Override - public Integer getInputState(String inputId) { - return delegate.getInputState(inputId); - } - - @Override - public void registerCallback(TvInputCallback internalCallback, Handler handler) { - delegate.registerCallback(internalCallback, handler); - } - - @Override - public void unregisterCallback(TvInputCallback internalCallback) { - delegate.unregisterCallback(internalCallback); - } - - @Override - public List<TvInputInfo> getTvInputList() { - return delegate.getTvInputList(); - } - - @Override - public List<TvContentRatingSystemInfo> getTvContentRatingSystemList() { - return delegate.getTvContentRatingSystemList(); - } - } - /** Types of HDMI device and bundled tuner. */ public static final int TYPE_CEC_DEVICE = -2; @@ -111,8 +57,7 @@ public class TvInputManagerHelper { private static final String PERMISSION_ACCESS_ALL_EPG_DATA = "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA"; - private static final String[] mPhysicalTunerBlackList = { - }; + private static final String[] mPhysicalTunerBlackList = {}; private static final String META_LABEL_SORT_KEY = "input_sort_key"; /** The default tv input priority to show. */ @@ -136,8 +81,7 @@ public class TvInputManagerHelper { DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_OTHER); } - private static final String[] PARTNER_TUNER_INPUT_PREFIX_BLACKLIST = { - }; + private static final String[] PARTNER_TUNER_INPUT_PREFIX_BLACKLIST = {}; private static final String[] TESTABLE_INPUTS = { "com.android.tv.testinput/.TestTvInputService" @@ -145,7 +89,7 @@ public class TvInputManagerHelper { private final Context mContext; private final PackageManager mPackageManager; - protected final TvInputManagerInterface mTvInputManager; + private final TvInputManager mTvInputManager; private final Map<String, Integer> mInputStateMap = new HashMap<>(); private final Map<String, TvInputInfo> mInputMap = new HashMap<>(); private final Map<String, String> mTvInputLabels = new ArrayMap<>(); @@ -262,23 +206,10 @@ public class TvInputManagerHelper { private final Comparator<TvInputInfo> mTvInputInfoComparator; public TvInputManagerHelper(Context context) { - this(context, createTvInputManagerWrapper(context)); - } - - @Nullable - protected static TvInputManagerImpl createTvInputManagerWrapper(Context context) { - TvInputManager tvInputManager = - (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); - return tvInputManager == null ? null : new TvInputManagerImpl(tvInputManager); - } - - @VisibleForTesting - protected TvInputManagerHelper( - Context context, @Nullable TvInputManagerInterface tvInputManager) { mContext = context.getApplicationContext(); mPackageManager = context.getPackageManager(); - mTvInputManager = tvInputManager; - mContentRatingsManager = new ContentRatingsManager(context, tvInputManager); + mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); + mContentRatingsManager = new ContentRatingsManager(context); mParentalControlSettings = new ParentalControlSettings(context); mTvInputInfoComparator = new InputComparatorInternal(this); } @@ -389,7 +320,7 @@ public class TvInputManagerHelper { /** Is the input one known bundled inputs not written by OEM/SOCs. */ public boolean isBundledInput(TvInputInfo inputInfo) { return inputInfo != null - && CommonUtils.isInBundledPackageSet( + && Utils.isInBundledPackageSet( inputInfo.getServiceInfo().applicationInfo.packageName); } @@ -497,17 +428,9 @@ public class TvInputManagerHelper { } return size; } - /** - * Returns TvInputInfo's input state. - * - * @param inputInfo - * @return An Integer which stands for the input state {@link - * TvInputManager.INPUT_STATE_DISCONNECTED} if inputInfo is null - */ - public int getInputState(@Nullable TvInputInfo inputInfo) { - return inputInfo == null - ? TvInputManager.INPUT_STATE_DISCONNECTED - : getInputState(inputInfo.getId()); + + public int getInputState(TvInputInfo inputInfo) { + return getInputState(inputInfo.getId()); } public int getInputState(String inputId) { @@ -578,15 +501,14 @@ public class TvInputManagerHelper { } private boolean isInBlackList(String inputId) { - if (TvFeatures.USE_PARTNER_INPUT_BLACKLIST.isEnabled(mContext)) { + if (Features.USE_PARTNER_INPUT_BLACKLIST.isEnabled(mContext)) { for (String disabledTunerInputPrefix : PARTNER_TUNER_INPUT_PREFIX_BLACKLIST) { if (inputId.contains(disabledTunerInputPrefix)) { return true; } } } - if (CommonUtils.isRoboTest()) return false; - if (CommonUtils.isRunningInTest()) { + if (TvCommonUtils.isRunningInTest()) { for (String testableInput : TESTABLE_INPUTS) { if (testableInput.equals(inputId)) { return false; diff --git a/src/com/android/tv/util/Utils.java b/src/com/android/tv/util/Utils.java index 1c8ccd5b..ac3be643 100644 --- a/src/com/android/tv/util/Utils.java +++ b/src/com/android/tv/util/Utils.java @@ -38,15 +38,20 @@ import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.text.format.DateUtils; +import android.util.ArraySet; import android.util.Log; import android.view.View; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; -import com.android.tv.TvSingletons; +import com.android.tv.TvApplication; +import com.android.tv.common.BuildConfig; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.data.GenreItems; import com.android.tv.data.Program; import com.android.tv.data.StreamInfo; +import com.android.tv.experiments.Experiments; +import java.io.File; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -63,11 +68,13 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; /** A class that includes convenience methods for accessing TvProvider database. */ -@SuppressWarnings("TryWithResources") // TODO(b/62143348): remove when error prone check fixed public class Utils { private static final String TAG = "Utils"; private static final boolean DEBUG = false; + private static final SimpleDateFormat ISO_8601 = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US); + public static final String EXTRA_KEY_ACTION = "action"; public static final String EXTRA_ACTION_SHOW_TV_INPUT = "show_tv_input"; public static final String EXTRA_KEY_FROM_LAUNCHER = "from_launcher"; @@ -109,6 +116,15 @@ public class Utils { private static final long HALF_MINUTE_MS = TimeUnit.SECONDS.toMillis(30); private static final long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1); + // Hardcoded list for known bundled inputs not written by OEM/SOCs. + // Bundled (system) inputs not in the list will get the high priority + // so they and their channels come first in the UI. + private static final Set<String> BUNDLED_PACKAGE_SET = new ArraySet<>(); + + static { + BUNDLED_PACKAGE_SET.add("com.android.tv"); + } + private enum AspectRatio { ASPECT_RATIO_4_3(4, 3), ASPECT_RATIO_16_9(16, 9), @@ -645,7 +661,7 @@ public class Utils { return null; } TvInputManagerHelper inputManager = - TvSingletons.getSingletons(context).getTvInputManagerHelper(); + TvApplication.getSingletons(context).getTvInputManagerHelper(); CharSequence customLabel = inputManager.loadCustomLabel(input); String label = (customLabel == null) ? null : customLabel.toString(); if (TextUtils.isEmpty(label)) { @@ -688,6 +704,11 @@ public class Utils { return toTimeString(timeMillis, true); } + /** Converts time in milliseconds to a ISO 8061 string. */ + public static String toIsoDateTimeString(long timeMillis) { + return ISO_8601.format(new Date(timeMillis)); + } + /** * Returns a {@link String} object which contains the layout information of the {@code view}. */ @@ -754,7 +775,7 @@ public class Utils { /** Checks where there is any internal TV input. */ public static boolean hasInternalTvInputs(Context context, boolean tunerInputOnly) { for (TvInputInfo input : - TvSingletons.getSingletons(context) + TvApplication.getSingletons(context) .getTvInputManagerHelper() .getTvInputInfos(true, tunerInputOnly)) { if (isInternalTvInput(context, input.getId())) { @@ -768,7 +789,7 @@ public class Utils { public static List<TvInputInfo> getInternalTvInputs(Context context, boolean tunerInputOnly) { List<TvInputInfo> inputs = new ArrayList<>(); for (TvInputInfo input : - TvSingletons.getSingletons(context) + TvApplication.getSingletons(context) .getTvInputManagerHelper() .getTvInputInfos(true, tunerInputOnly)) { if (isInternalTvInput(context, input.getId())) { @@ -796,22 +817,47 @@ public class Utils { /** Returns the TV input for the given channel ID. */ @Nullable public static TvInputInfo getTvInputInfoForChannelId(Context context, long channelId) { - TvSingletons tvSingletons = TvSingletons.getSingletons(context); - Channel channel = tvSingletons.getChannelDataManager().getChannel(channelId); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + Channel channel = appSingletons.getChannelDataManager().getChannel(channelId); if (channel == null) { return null; } - return tvSingletons.getTvInputManagerHelper().getTvInputInfo(channel.getInputId()); + return appSingletons.getTvInputManagerHelper().getTvInputInfo(channel.getInputId()); } /** Returns the {@link TvInputInfo} for the given input ID. */ @Nullable public static TvInputInfo getTvInputInfoForInputId(Context context, String inputId) { - return TvSingletons.getSingletons(context) + return TvApplication.getSingletons(context) .getTvInputManagerHelper() .getTvInputInfo(inputId); } + /** Deletes a file or a directory. */ + public static void deleteDirOrFile(File fileOrDirectory) { + if (fileOrDirectory.isDirectory()) { + for (File child : fileOrDirectory.listFiles()) { + deleteDirOrFile(child); + } + } + fileOrDirectory.delete(); + } + + /** Checks whether a given package is in our bundled package set. */ + public static boolean isInBundledPackageSet(String packageName) { + return BUNDLED_PACKAGE_SET.contains(packageName); + } + + /** Checks whether a given input is a bundled input. */ + public static boolean isBundledInput(String inputId) { + for (String prefix : BUNDLED_PACKAGE_SET) { + if (inputId.startsWith(prefix + "/")) { + return true; + } + } + return false; + } + /** Returns the canonical genre ID's from the {@code genres}. */ public static int[] getCanonicalGenreIds(String genres) { if (TextUtils.isEmpty(genres)) { @@ -853,6 +899,11 @@ public class Utils { return Genres.encode(genres); } + /** Returns true if the current user is a developer. */ + public static boolean isDeveloper() { + return BuildConfig.ENG || Experiments.ENABLE_DEVELOPER_FEATURES.get(); + } + /** * Runs the method in main thread. If the current thread is not main thread, block it util the * method is finished. diff --git a/src/com/android/tv/util/ViewCache.java b/src/com/android/tv/util/ViewCache.java index b8bdb6b8..2d5ecfe6 100644 --- a/src/com/android/tv/util/ViewCache.java +++ b/src/com/android/tv/util/ViewCache.java @@ -1,18 +1,3 @@ -/* - * 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.util; import android.content.Context; diff --git a/src/com/android/tv/util/account/AccountHelper.java b/src/com/android/tv/util/account/AccountHelper.java deleted file mode 100644 index e98b42ec..00000000 --- a/src/com/android/tv/util/account/AccountHelper.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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.util.account; - -import android.accounts.Account; -import android.support.annotation.Nullable; - -/** Helper methods for getting and selecting a user account. */ -public interface AccountHelper { - /** Returns the currently selected account or {@code null} if none is selected. */ - @Nullable - Account getSelectedAccount(); - /** - * Selects the first account available. - * - * @return selected account or {@code null} if none is selected. - */ - @Nullable - Account selectFirstAccount(); - - /** Returns all eligible accounts . */ - @Nullable - Account getFirstEligibleAccount(); -} |