From eda393ba824e3345cdef20f060377a71e54c1a18 Mon Sep 17 00:00:00 2001 From: meteoorkip Date: Sat, 31 Dec 2016 22:48:14 +0100 Subject: [PATCH 01/92] Add default artwork support to SimpleExoPlayerView Add support for a default artwork image that is displayed if no artwork can be found in the metadata. --- .../exoplayer2/ui/SimpleExoPlayerView.java | 64 +++++++++++++++---- library/src/main/res/values/attrs.xml | 1 + 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java b/library/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java index d094266fcc..8ac0c64082 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java +++ b/library/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java @@ -65,6 +65,13 @@ import java.util.List; *
  • Default: {@code true}
  • * * + *
  • {@code default_artwork} - Default artwork to use if no artwork available in audio + * streams. + * + *
  • *
  • {@code use_controller} - Whether playback controls are displayed. * *
  • - *
  • {@code default_artwork} - Default artwork to use if no artwork available in audio - * streams. - *
      - *
    • Corresponding method: {@link #setDefaultArtwork(Bitmap)}
    • - *
    • Default: {@code null}
    • - *
    - *
  • *
  • {@code use_controller} - Whether playback controls are displayed. * *
  • + *
  • {@code default_artwork} - Default artwork to use if no artwork available in audio + * streams. + *
      + *
    • Corresponding method: {@link #setDefaultArtwork(Bitmap)}
    • + *
    • Default: {@code null}
    • + *
    + *
  • *
  • {@code use_controller} - Whether playback controls are displayed. * */ SubtitleDecoderFactory DEFAULT = new SubtitleDecoderFactory() { @@ -78,6 +80,9 @@ public interface SubtitleDecoderFactory { || format.sampleMimeType.equals(MimeTypes.APPLICATION_MP4CEA608)) { return clazz.asSubclass(SubtitleDecoder.class).getConstructor(String.class, Integer.TYPE) .newInstance(format.sampleMimeType, format.accessibilityChannel); + } else if (format.sampleMimeType.equals(MimeTypes.APPLICATION_CEA708)) { + return clazz.asSubclass(SubtitleDecoder.class).getConstructor(Integer.TYPE) + .newInstance(format.accessibilityChannel); } else { return clazz.asSubclass(SubtitleDecoder.class).getConstructor().newInstance(); } @@ -105,6 +110,8 @@ public interface SubtitleDecoderFactory { case MimeTypes.APPLICATION_CEA608: case MimeTypes.APPLICATION_MP4CEA608: return Class.forName("com.google.android.exoplayer2.text.cea.Cea608Decoder"); + case MimeTypes.APPLICATION_CEA708: + return Class.forName("com.google.android.exoplayer2.text.cea.Cea708Decoder"); default: return null; } diff --git a/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Cue.java b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Cue.java new file mode 100644 index 0000000000..e63d1d4118 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Cue.java @@ -0,0 +1,67 @@ +/* + * 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.exoplayer2.text.cea; + +import android.text.Layout.Alignment; +import com.google.android.exoplayer2.text.Cue; + +/** + * A {@link Cue} for CEA-708. + */ +/* package */ final class Cea708Cue extends Cue implements Comparable { + + /** + * An unset priority. + */ + public static final int PRIORITY_UNSET = -1; + + /** + * The priority of the cue box. + */ + public final int priority; + + /** + * @param text See {@link #text}. + * @param textAlignment See {@link #textAlignment}. + * @param line See {@link #line}. + * @param lineType See {@link #lineType}. + * @param lineAnchor See {@link #lineAnchor}. + * @param position See {@link #position}. + * @param positionAnchor See {@link #positionAnchor}. + * @param size See {@link #size}. + * @param windowColorSet See {@link #windowColorSet}. + * @param windowColor See {@link #windowColor}. + * @param priority See (@link #priority}. + */ + public Cea708Cue(CharSequence text, Alignment textAlignment, float line, @LineType int lineType, + @AnchorType int lineAnchor, float position, @AnchorType int positionAnchor, float size, + boolean windowColorSet, int windowColor, int priority) { + super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, size, + windowColorSet, windowColor); + this.priority = priority; + } + + @Override + public int compareTo(Cea708Cue other) { + if (other.priority < priority) { + return -1; + } else if (other.priority > priority) { + return 1; + } + return 0; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java new file mode 100644 index 0000000000..5ca5ce1270 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java @@ -0,0 +1,1225 @@ +/* + * 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.exoplayer2.text.cea; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.Layout.Alignment; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import android.util.Log; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.Cue.AnchorType; +import com.google.android.exoplayer2.text.Subtitle; +import com.google.android.exoplayer2.text.SubtitleDecoder; +import com.google.android.exoplayer2.text.SubtitleInputBuffer; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * A {@link SubtitleDecoder} for CEA-708 (also known as "EIA-708"). + * + *

    This implementation does not provide full compatibility with the CEA-708 specification. Note + * that only the default pen/text and window/cue colors (i.e. text with + * {@link CueBuilder#COLOR_SOLID_WHITE} foreground and {@link CueBuilder#COLOR_SOLID_BLACK} + * background, and cues with {@link CueBuilder#COLOR_SOLID_BLACK} fill) will be overridden with + * device accessibility settings; all others will use the colors and opacity specified by the + * caption data. + */ +public final class Cea708Decoder extends CeaDecoder { + + private static final String TAG = "Cea708Decoder"; + + private static final int NUM_WINDOWS = 8; + + private static final int DTVCC_PACKET_DATA = 0x02; + private static final int DTVCC_PACKET_START = 0x03; + private static final int CC_VALID_FLAG = 0x04; + + // Base Commands + private static final int GROUP_C0_END = 0x1F; // Miscellaneous Control Codes + private static final int GROUP_G0_END = 0x7F; // ASCII Printable Characters + private static final int GROUP_C1_END = 0x9F; // Captioning Command Control Codes + private static final int GROUP_G1_END = 0xFF; // ISO 8859-1 LATIN-1 Character Set + + // Extended Commands + private static final int GROUP_C2_END = 0x1F; // Extended Control Code Set 1 + private static final int GROUP_G2_END = 0x7F; // Extended Miscellaneous Characters + private static final int GROUP_C3_END = 0x9F; // Extended Control Code Set 2 + private static final int GROUP_G3_END = 0xFF; // Future Expansion + + // Group C0 Commands + private static final int COMMAND_NUL = 0x00; // Nul + private static final int COMMAND_ETX = 0x03; // EndOfText + private static final int COMMAND_BS = 0x08; // Backspace + private static final int COMMAND_FF = 0x0C; // FormFeed (Flush) + private static final int COMMAND_CR = 0x0D; // CarriageReturn + private static final int COMMAND_HCR = 0x0E; // ClearLine + private static final int COMMAND_EXT1 = 0x10; // Extended Control Code Flag + private static final int COMMAND_EXT1_START = 0x11; + private static final int COMMAND_EXT1_END = 0x17; + private static final int COMMAND_P16_START = 0x18; + private static final int COMMAND_P16_END = 0x1F; + + // Group C1 Commands + private static final int COMMAND_CW0 = 0x80; // SetCurrentWindow to 0 + private static final int COMMAND_CW1 = 0x81; // SetCurrentWindow to 1 + private static final int COMMAND_CW2 = 0x82; // SetCurrentWindow to 2 + private static final int COMMAND_CW3 = 0x83; // SetCurrentWindow to 3 + private static final int COMMAND_CW4 = 0x84; // SetCurrentWindow to 4 + private static final int COMMAND_CW5 = 0x85; // SetCurrentWindow to 5 + private static final int COMMAND_CW6 = 0x86; // SetCurrentWindow to 6 + private static final int COMMAND_CW7 = 0x87; // SetCurrentWindow to 7 + private static final int COMMAND_CLW = 0x88; // ClearWindows (+1 byte) + private static final int COMMAND_DSW = 0x89; // DisplayWindows (+1 byte) + private static final int COMMAND_HDW = 0x8A; // HideWindows (+1 byte) + private static final int COMMAND_TGW = 0x8B; // ToggleWindows (+1 byte) + private static final int COMMAND_DLW = 0x8C; // DeleteWindows (+1 byte) + private static final int COMMAND_DLY = 0x8D; // Delay (+1 byte) + private static final int COMMAND_DLC = 0x8E; // DelayCancel + private static final int COMMAND_RST = 0x8F; // Reset + private static final int COMMAND_SPA = 0x90; // SetPenAttributes (+2 bytes) + private static final int COMMAND_SPC = 0x91; // SetPenColor (+3 bytes) + private static final int COMMAND_SPL = 0x92; // SetPenLocation (+2 bytes) + private static final int COMMAND_SWA = 0x97; // SetWindowAttributes (+4 bytes) + private static final int COMMAND_DF0 = 0x98; // DefineWindow 0 (+6 bytes) + private static final int COMMAND_DF1 = 0x99; // DefineWindow 1 (+6 bytes) + private static final int COMMAND_DF2 = 0x9A; // DefineWindow 2 (+6 bytes) + private static final int COMMAND_DF3 = 0x9B; // DefineWindow 3 (+6 bytes) + private static final int COMMAND_DS4 = 0x9C; // DefineWindow 4 (+6 bytes) + private static final int COMMAND_DF5 = 0x9D; // DefineWindow 5 (+6 bytes) + private static final int COMMAND_DF6 = 0x9E; // DefineWindow 6 (+6 bytes) + private static final int COMMAND_DF7 = 0x9F; // DefineWindow 7 (+6 bytes) + + // G0 Table Special Chars + private static final int CHARACTER_MN = 0x7F; // MusicNote + + // G2 Table Special Chars + private static final int CHARACTER_TSP = 0x20; + private static final int CHARACTER_NBTSP = 0x21; + private static final int CHARACTER_ELLIPSIS = 0x25; + private static final int CHARACTER_BIG_CARONS = 0x2A; + private static final int CHARACTER_BIG_OE = 0x2C; + private static final int CHARACTER_SOLID_BLOCK = 0x30; + private static final int CHARACTER_OPEN_SINGLE_QUOTE = 0x31; + private static final int CHARACTER_CLOSE_SINGLE_QUOTE = 0x32; + private static final int CHARACTER_OPEN_DOUBLE_QUOTE = 0x33; + private static final int CHARACTER_CLOSE_DOUBLE_QUOTE = 0x34; + private static final int CHARACTER_BOLD_BULLET = 0x35; + private static final int CHARACTER_TM = 0x39; + private static final int CHARACTER_SMALL_CARONS = 0x3A; + private static final int CHARACTER_SMALL_OE = 0x3C; + private static final int CHARACTER_SM = 0x3D; + private static final int CHARACTER_DIAERESIS_Y = 0x3F; + private static final int CHARACTER_ONE_EIGHTH = 0x76; + private static final int CHARACTER_THREE_EIGHTHS = 0x77; + private static final int CHARACTER_FIVE_EIGHTHS = 0x78; + private static final int CHARACTER_SEVEN_EIGHTHS = 0x79; + private static final int CHARACTER_VERTICAL_BORDER = 0x7A; + private static final int CHARACTER_UPPER_RIGHT_BORDER = 0x7B; + private static final int CHARACTER_LOWER_LEFT_BORDER = 0x7C; + private static final int CHARACTER_HORIZONTAL_BORDER = 0x7D; + private static final int CHARACTER_LOWER_RIGHT_BORDER = 0x7E; + private static final int CHARACTER_UPPER_LEFT_BORDER = 0x7F; + + private final ParsableByteArray ccData; + private final ParsableBitArray serviceBlockPacket; + + private final int selectedServiceNumber; + private final CueBuilder[] cueBuilders; + + private CueBuilder currentCueBuilder; + private List cues; + private List lastCues; + + private DtvCcPacket currentDtvCcPacket; + private int currentWindow; + + public Cea708Decoder(int accessibilityChannel) { + ccData = new ParsableByteArray(); + serviceBlockPacket = new ParsableBitArray(); + selectedServiceNumber = (accessibilityChannel == Format.NO_VALUE) ? 1 : accessibilityChannel; + + cueBuilders = new CueBuilder[NUM_WINDOWS]; + for (int i = 0; i < NUM_WINDOWS; i++) { + cueBuilders[i] = new CueBuilder(); + } + + currentCueBuilder = cueBuilders[0]; + resetCueBuilders(); + } + + @Override + public String getName() { + return "Cea708Decoder"; + } + + @Override + public void flush() { + super.flush(); + cues = null; + lastCues = null; + currentWindow = 0; + currentCueBuilder = cueBuilders[currentWindow]; + resetCueBuilders(); + currentDtvCcPacket = null; + } + + @Override + protected boolean isNewSubtitleDataAvailable() { + return cues != lastCues; + } + + @Override + protected Subtitle createSubtitle() { + lastCues = cues; + return new CeaSubtitle(cues); + } + + @Override + protected void decode(SubtitleInputBuffer inputBuffer) { + ccData.reset(inputBuffer.data.array(), inputBuffer.data.limit()); + while (ccData.bytesLeft() >= 3) { + int ccTypeAndValid = (ccData.readUnsignedByte() & 0x07); + + int ccType = ccTypeAndValid & (DTVCC_PACKET_DATA | DTVCC_PACKET_START); + boolean ccValid = (ccTypeAndValid & CC_VALID_FLAG) == CC_VALID_FLAG; + byte ccData1 = (byte) ccData.readUnsignedByte(); + byte ccData2 = (byte) ccData.readUnsignedByte(); + + // Ignore any non-CEA-708 data + if (ccType != DTVCC_PACKET_DATA && ccType != DTVCC_PACKET_START) { + continue; + } + + if (!ccValid) { + finalizeCurrentPacket(); + continue; + } + + if (ccType == DTVCC_PACKET_START) { + finalizeCurrentPacket(); + + int sequenceNumber = (ccData1 & 0xC0) >> 6; // first 2 bits + int packetSize = ccData1 & 0x3F; // last 6 bits + if (packetSize == 0) { + packetSize = 64; + } + + currentDtvCcPacket = new DtvCcPacket(sequenceNumber, packetSize); + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2; + } else { + // The only remaining valid packet type is DTVCC_PACKET_DATA + Assertions.checkArgument(ccType == DTVCC_PACKET_DATA); + + if (currentDtvCcPacket == null) { + Log.e(TAG, "Encountered DTVCC_PACKET_DATA before DTVCC_PACKET_START"); + continue; + } + + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData1; + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2; + } + + if (currentDtvCcPacket.currentIndex == (currentDtvCcPacket.packetSize * 2 - 1)) { + finalizeCurrentPacket(); + } + } + } + + private void finalizeCurrentPacket() { + if (currentDtvCcPacket == null) { + // No packet to finalize; + return; + } + + processCurrentPacket(); + currentDtvCcPacket = null; + } + + private void processCurrentPacket() { + if (currentDtvCcPacket.currentIndex != (currentDtvCcPacket.packetSize * 2 - 1)) { + Log.w(TAG, "DtvCcPacket ended prematurely; size is " + (currentDtvCcPacket.packetSize * 2 - 1) + + ", but current index is " + currentDtvCcPacket.currentIndex + " (sequence number " + + currentDtvCcPacket.sequenceNumber + ")"); + } + + serviceBlockPacket.reset(currentDtvCcPacket.packetData, currentDtvCcPacket.currentIndex); + + int serviceNumber = serviceBlockPacket.readBits(3); + int blockSize = serviceBlockPacket.readBits(5); + if (serviceNumber == 7) { + // extended service numbers + serviceBlockPacket.skipBits(2); + serviceNumber += serviceBlockPacket.readBits(6); + } + + // Ignore packets in which blockSize is 0 + if (blockSize == 0) { + if (serviceNumber != 0) { + Log.w(TAG, "serviceNumber is non-zero (" + serviceNumber + ") when blockSize is 0"); + } + return; + } + + if (serviceNumber != selectedServiceNumber) { + return; + } + + while (serviceBlockPacket.bitsLeft() > 0) { + int command = serviceBlockPacket.readBits(8); + if (command != COMMAND_EXT1) { + if (command <= GROUP_C0_END) { + handleC0Command(command); + } else if (command <= GROUP_G0_END) { + handleG0Character(command); + } else if (command <= GROUP_C1_END) { + handleC1Command(command); + // Cues are always updated after a C1 command + cues = getDisplayCues(); + } else if (command <= GROUP_G1_END) { + handleG1Character(command); + } else { + Log.w(TAG, "Invalid base command: " + command); + } + } else { + // Read the extended command + command = serviceBlockPacket.readBits(8); + if (command <= GROUP_C2_END) { + handleC2Command(command); + } else if (command <= GROUP_G2_END) { + handleG2Character(command); + } else if (command <= GROUP_C3_END) { + handleC3Command(command); + } else if (command <= GROUP_G3_END) { + handleG3Character(command); + } else { + Log.w(TAG, "Invalid extended command: " + command); + } + } + } + } + + private void handleC0Command(int command) { + switch (command) { + case COMMAND_NUL: + // Do nothing. + break; + case COMMAND_ETX: + cues = getDisplayCues(); + break; + case COMMAND_BS: + currentCueBuilder.backspace(); + break; + case COMMAND_FF: + resetCueBuilders(); + break; + case COMMAND_CR: + currentCueBuilder.append('\n'); + break; + case COMMAND_HCR: + // TODO: Add support for this command. + break; + default: + if (command >= COMMAND_EXT1_START && command <= COMMAND_EXT1_END) { + Log.w(TAG, "Currently unsupported COMMAND_EXT1 Command: " + command); + serviceBlockPacket.skipBits(8); + } else if (command >= COMMAND_P16_START && command <= COMMAND_P16_END) { + Log.w(TAG, "Currently unsupported COMMAND_P16 Command: " + command); + serviceBlockPacket.skipBits(16); + } else { + Log.w(TAG, "Invalid C0 command: " + command); + } + } + } + + private void handleC1Command(int command) { + int window; + switch (command) { + case COMMAND_CW0: + case COMMAND_CW1: + case COMMAND_CW2: + case COMMAND_CW3: + case COMMAND_CW4: + case COMMAND_CW5: + case COMMAND_CW6: + case COMMAND_CW7: + window = (command - COMMAND_CW0); + if (currentWindow != window) { + currentWindow = window; + currentCueBuilder = cueBuilders[window]; + } + break; + case COMMAND_CLW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].clear(); + } + } + break; + case COMMAND_DSW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].setVisibility(true); + } + } + break; + case COMMAND_HDW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].setVisibility(false); + } + } + break; + case COMMAND_TGW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + CueBuilder cueBuilder = cueBuilders[NUM_WINDOWS - i]; + cueBuilder.setVisibility(!cueBuilder.isVisible()); + } + } + break; + case COMMAND_DLW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].reset(); + } + } + break; + case COMMAND_DLY: + // TODO: Add support for delay commands. + serviceBlockPacket.skipBits(8); + break; + case COMMAND_DLC: + // TODO: Add support for delay commands. + break; + case COMMAND_RST: + resetCueBuilders(); + break; + case COMMAND_SPA: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(16); + } else { + handleSetPenAttributes(); + } + break; + case COMMAND_SPC: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(24); + } else { + handleSetPenColor(); + } + break; + case COMMAND_SPL: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(16); + } else { + handleSetPenLocation(); + } + break; + case COMMAND_SWA: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(32); + } else { + handleSetWindowAttributes(); + } + break; + case COMMAND_DF0: + case COMMAND_DF1: + case COMMAND_DF2: + case COMMAND_DF3: + case COMMAND_DS4: + case COMMAND_DF5: + case COMMAND_DF6: + case COMMAND_DF7: + window = (command - COMMAND_DF0); + handleDefineWindow(window); + break; + default: + Log.w(TAG, "Invalid C1 command: " + command); + } + } + + private void handleC2Command(int command) { + // C2 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes + if (command <= 0x0F) { + // Do nothing. + } else if (command <= 0x0F) { + serviceBlockPacket.skipBits(8); + } else if (command <= 0x17) { + serviceBlockPacket.skipBits(16); + } else if (command <= 0x1F) { + serviceBlockPacket.skipBits(24); + } + } + + private void handleC3Command(int command) { + // C3 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes + if (command <= 0x87) { + serviceBlockPacket.skipBits(32); + } else if (command <= 0x8F) { + serviceBlockPacket.skipBits(40); + } else if (command <= 0x9F) { + // 90-9F are variable length codes; the first byte defines the header with the first + // 2 bits specifying the type and the last 6 bits specifying the remaining length of the + // command in bytes + serviceBlockPacket.skipBits(2); + int length = serviceBlockPacket.readBits(6); + serviceBlockPacket.skipBits(8 * length); + } + } + + private void handleG0Character(int characterCode) { + if (characterCode == CHARACTER_MN) { + currentCueBuilder.append('\u266B'); + } else { + currentCueBuilder.append((char) (characterCode & 0xFF)); + } + } + + private void handleG1Character(int characterCode) { + currentCueBuilder.append((char) (characterCode & 0xFF)); + } + + private void handleG2Character(int characterCode) { + switch (characterCode) { + case CHARACTER_TSP: + currentCueBuilder.append('\u0020'); + break; + case CHARACTER_NBTSP: + currentCueBuilder.append('\u00A0'); + break; + case CHARACTER_ELLIPSIS: + currentCueBuilder.append('\u2026'); + break; + case CHARACTER_BIG_CARONS: + currentCueBuilder.append('\u0160'); + break; + case CHARACTER_BIG_OE: + currentCueBuilder.append('\u0152'); + break; + case CHARACTER_SOLID_BLOCK: + currentCueBuilder.append('\u2588'); + break; + case CHARACTER_OPEN_SINGLE_QUOTE: + currentCueBuilder.append('\u2018'); + break; + case CHARACTER_CLOSE_SINGLE_QUOTE: + currentCueBuilder.append('\u2019'); + break; + case CHARACTER_OPEN_DOUBLE_QUOTE: + currentCueBuilder.append('\u201C'); + break; + case CHARACTER_CLOSE_DOUBLE_QUOTE: + currentCueBuilder.append('\u201D'); + break; + case CHARACTER_BOLD_BULLET: + currentCueBuilder.append('\u2022'); + break; + case CHARACTER_TM: + currentCueBuilder.append('\u2122'); + break; + case CHARACTER_SMALL_CARONS: + currentCueBuilder.append('\u0161'); + break; + case CHARACTER_SMALL_OE: + currentCueBuilder.append('\u0153'); + break; + case CHARACTER_SM: + currentCueBuilder.append('\u2120'); + break; + case CHARACTER_DIAERESIS_Y: + currentCueBuilder.append('\u0178'); + break; + case CHARACTER_ONE_EIGHTH: + currentCueBuilder.append('\u215B'); + break; + case CHARACTER_THREE_EIGHTHS: + currentCueBuilder.append('\u215C'); + break; + case CHARACTER_FIVE_EIGHTHS: + currentCueBuilder.append('\u215D'); + break; + case CHARACTER_SEVEN_EIGHTHS: + currentCueBuilder.append('\u215E'); + break; + case CHARACTER_VERTICAL_BORDER: + currentCueBuilder.append('\u2502'); + break; + case CHARACTER_UPPER_RIGHT_BORDER: + currentCueBuilder.append('\u2510'); + break; + case CHARACTER_LOWER_LEFT_BORDER: + currentCueBuilder.append('\u2514'); + break; + case CHARACTER_HORIZONTAL_BORDER: + currentCueBuilder.append('\u2500'); + break; + case CHARACTER_LOWER_RIGHT_BORDER: + currentCueBuilder.append('\u2518'); + break; + case CHARACTER_UPPER_LEFT_BORDER: + currentCueBuilder.append('\u250C'); + break; + default: + Log.w(TAG, "Invalid G2 character: " + characterCode); + // The CEA-708 specification doesn't specify what to do in the case of an unexpected + // value in the G2 character range, so we ignore it. + } + } + + private void handleG3Character(int characterCode) { + if (characterCode == 0xA0) { + currentCueBuilder.append('\u33C4'); + } else { + Log.w(TAG, "Invalid G3 character: " + characterCode); + // Substitute any unsupported G3 character with an underscore as per CEA-708 specification. + currentCueBuilder.append('_'); + } + } + + private void handleSetPenAttributes() { + // the SetPenAttributes command contains 2 bytes of data + // first byte + int textTag = serviceBlockPacket.readBits(4); + int offset = serviceBlockPacket.readBits(2); + int penSize = serviceBlockPacket.readBits(2); + // second byte + boolean italicsToggle = serviceBlockPacket.readBit(); + boolean underlineToggle = serviceBlockPacket.readBit(); + int edgeType = serviceBlockPacket.readBits(3); + int fontStyle = serviceBlockPacket.readBits(3); + + currentCueBuilder.setPenAttributes(textTag, offset, penSize, italicsToggle, underlineToggle, + edgeType, fontStyle); + } + + private void handleSetPenColor() { + // the SetPenColor command contains 3 bytes of data + // first byte + int foregroundO = serviceBlockPacket.readBits(2); + int foregroundR = serviceBlockPacket.readBits(2); + int foregroundG = serviceBlockPacket.readBits(2); + int foregroundB = serviceBlockPacket.readBits(2); + int foregroundColor = CueBuilder.getArgbColorFromCeaColor(foregroundR, foregroundG, foregroundB, + foregroundO); + // second byte + int backgroundO = serviceBlockPacket.readBits(2); + int backgroundR = serviceBlockPacket.readBits(2); + int backgroundG = serviceBlockPacket.readBits(2); + int backgroundB = serviceBlockPacket.readBits(2); + int backgroundColor = CueBuilder.getArgbColorFromCeaColor(backgroundR, backgroundG, backgroundB, + backgroundO); + // third byte + serviceBlockPacket.skipBits(2); // null padding + int edgeR = serviceBlockPacket.readBits(2); + int edgeG = serviceBlockPacket.readBits(2); + int edgeB = serviceBlockPacket.readBits(2); + int edgeColor = CueBuilder.getArgbColorFromCeaColor(edgeR, edgeG, edgeB); + + currentCueBuilder.setPenColor(foregroundColor, backgroundColor, edgeColor); + } + + private void handleSetPenLocation() { + // the SetPenLocation command contains 2 bytes of data + // first byte + serviceBlockPacket.skipBits(4); + int row = serviceBlockPacket.readBits(4); + // second byte + serviceBlockPacket.skipBits(2); + int column = serviceBlockPacket.readBits(6); + + currentCueBuilder.setPenLocation(row, column); + } + + private void handleSetWindowAttributes() { + // the SetWindowAttributes command contains 4 bytes of data + // first byte + int fillO = serviceBlockPacket.readBits(2); + int fillR = serviceBlockPacket.readBits(2); + int fillG = serviceBlockPacket.readBits(2); + int fillB = serviceBlockPacket.readBits(2); + int fillColor = CueBuilder.getArgbColorFromCeaColor(fillR, fillG, fillB, fillO); + // second byte + int borderType = serviceBlockPacket.readBits(2); // only the lower 2 bits of borderType + int borderR = serviceBlockPacket.readBits(2); + int borderG = serviceBlockPacket.readBits(2); + int borderB = serviceBlockPacket.readBits(2); + int borderColor = CueBuilder.getArgbColorFromCeaColor(borderR, borderG, borderB); + // third byte + if (serviceBlockPacket.readBit()) { + borderType |= 0x04; // set the top bit of the 3-bit borderType + } + boolean wordWrapToggle = serviceBlockPacket.readBit(); + int printDirection = serviceBlockPacket.readBits(2); + int scrollDirection = serviceBlockPacket.readBits(2); + int justification = serviceBlockPacket.readBits(2); + // fourth byte + // Note that we don't intend to support display effects + serviceBlockPacket.skipBits(8); // effectSpeed(4), effectDirection(2), displayEffect(2) + + currentCueBuilder.setWindowAttributes(fillColor, borderColor, wordWrapToggle, borderType, + printDirection, scrollDirection, justification); + } + + private void handleDefineWindow(int window) { + CueBuilder cueBuilder = cueBuilders[window]; + + // the DefineWindow command contains 6 bytes of data + // first byte + serviceBlockPacket.skipBits(2); // null padding + boolean visible = serviceBlockPacket.readBit(); + boolean rowLock = serviceBlockPacket.readBit(); + boolean columnLock = serviceBlockPacket.readBit(); + int priority = serviceBlockPacket.readBits(3); + // second byte + boolean relativePositioning = serviceBlockPacket.readBit(); + int verticalAnchor = serviceBlockPacket.readBits(7); + // third byte + int horizontalAnchor = serviceBlockPacket.readBits(8); + // fourth byte + int anchorId = serviceBlockPacket.readBits(4); + int rowCount = serviceBlockPacket.readBits(4); + // fifth byte + serviceBlockPacket.skipBits(2); // null padding + int columnCount = serviceBlockPacket.readBits(6); + // sixth byte + serviceBlockPacket.skipBits(2); // null padding + int windowStyle = serviceBlockPacket.readBits(3); + int penStyle = serviceBlockPacket.readBits(3); + + cueBuilder.defineWindow(visible, rowLock, columnLock, priority, relativePositioning, + verticalAnchor, horizontalAnchor, rowCount, columnCount, anchorId, windowStyle, penStyle); + } + + private List getDisplayCues() { + List displayCues = new ArrayList<>(); + for (int i = 0; i < NUM_WINDOWS; i++) { + if (!cueBuilders[i].isEmpty() && cueBuilders[i].isVisible()) { + displayCues.add(cueBuilders[i].build()); + } + } + Collections.sort(displayCues); + return Collections.unmodifiableList(displayCues); + } + + private void resetCueBuilders() { + for (int i = 0; i < NUM_WINDOWS; i++) { + cueBuilders[i].reset(); + } + } + + private static final class DtvCcPacket { + + public final int sequenceNumber; + public final int packetSize; + public final byte[] packetData; + + int currentIndex; + + public DtvCcPacket(int sequenceNumber, int packetSize) { + this.sequenceNumber = sequenceNumber; + this.packetSize = packetSize; + packetData = new byte[2 * packetSize - 1]; + currentIndex = 0; + } + + } + + // TODO: There is a lot of overlap between Cea708Decoder.CueBuilder and Cea608Decoder.CueBuilder + // which could be refactored into a separate class. + private static final class CueBuilder { + + private static final int RELATIVE_CUE_SIZE = 99; + private static final int VERTICAL_SIZE = 74; + private static final int HORIZONTAL_SIZE = 209; + + private static final int DEFAULT_PRIORITY = 4; + + private static final int MAXIMUM_ROW_COUNT = 15; + + private static final int JUSTIFICATION_LEFT = 0; + private static final int JUSTIFICATION_RIGHT = 1; + private static final int JUSTIFICATION_CENTER = 2; + private static final int JUSTIFICATION_FULL = 3; + + private static final int DIRECTION_LEFT_TO_RIGHT = 0; + private static final int DIRECTION_RIGHT_TO_LEFT = 1; + private static final int DIRECTION_TOP_TO_BOTTOM = 2; + private static final int DIRECTION_BOTTOM_TO_TOP = 3; + + // TODO: Add other border/edge types when utilized. + private static final int BORDER_AND_EDGE_TYPE_NONE = 0; + private static final int BORDER_AND_EDGE_TYPE_UNIFORM = 3; + + public static final int COLOR_SOLID_WHITE = getArgbColorFromCeaColor(2, 2, 2, 0); + public static final int COLOR_SOLID_BLACK = getArgbColorFromCeaColor(0, 0, 0, 0); + public static final int COLOR_TRANSPARENT = getArgbColorFromCeaColor(0, 0, 0, 3); + + // TODO: Add other sizes when utilized. + private static final int PEN_SIZE_STANDARD = 1; + + // TODO: Add other pen font styles when utilized. + private static final int PEN_FONT_STYLE_DEFAULT = 0; + private static final int PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS = 1; + private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS = 2; + private static final int PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS = 3; + private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS = 4; + + // TODO: Add other pen offsets when utilized. + private static final int PEN_OFFSET_NORMAL = 1; + + // The window style properties are specified in the CEA-708 specification. + private static final int[] WINDOW_STYLE_JUSTIFICATION = new int[]{ + JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, + JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_CENTER, + JUSTIFICATION_LEFT + }; + private static final int[] WINDOW_STYLE_PRINT_DIRECTION = new int[]{ + DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, + DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, + DIRECTION_TOP_TO_BOTTOM + }; + private static final int[] WINDOW_STYLE_SCROLL_DIRECTION = new int[]{ + DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, + DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, + DIRECTION_RIGHT_TO_LEFT + }; + private static final boolean[] WINDOW_STYLE_WORD_WRAP = new boolean[]{ + false, false, false, true, true, true, false + }; + private static final int[] WINDOW_STYLE_FILL = new int[]{ + COLOR_SOLID_BLACK, COLOR_TRANSPARENT, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, + COLOR_TRANSPARENT, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK + }; + + // The pen style properties are specified in the CEA-708 specification. + private static final int[] PEN_STYLE_FONT_STYLE = new int[]{ + PEN_FONT_STYLE_DEFAULT, PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS, PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS + }; + private static final int[] PEN_STYLE_EDGE_TYPE = new int[]{ + BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, + BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_UNIFORM, + BORDER_AND_EDGE_TYPE_UNIFORM + }; + private static final int[] PEN_STYLE_BACKGROUND = new int[]{ + COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, + COLOR_SOLID_BLACK, COLOR_TRANSPARENT, COLOR_TRANSPARENT}; + + private final List rolledUpCaptions; + private final SpannableStringBuilder captionStringBuilder; + + // Window/Cue properties + private boolean defined; + private boolean visible; + private int priority; + private boolean relativePositioning; + private int verticalAnchor; + private int horizontalAnchor; + private int anchorId; + private int rowCount; + private boolean rowLock; + private int justification; + private int windowStyleId; + private int penStyleId; + private int windowFillColor; + + // Pen/Text properties + private int italicsStartPosition; + private int underlineStartPosition; + private int foregroundColorStartPosition; + private int foregroundColor; + private int backgroundColorStartPosition; + private int backgroundColor; + + public CueBuilder() { + rolledUpCaptions = new LinkedList<>(); + captionStringBuilder = new SpannableStringBuilder(); + reset(); + } + + public boolean isEmpty() { + return !isDefined() || (rolledUpCaptions.isEmpty() && captionStringBuilder.length() == 0); + } + + public void reset() { + clear(); + + defined = false; + visible = false; + priority = DEFAULT_PRIORITY; + relativePositioning = false; + verticalAnchor = 0; + horizontalAnchor = 0; + anchorId = 0; + rowCount = MAXIMUM_ROW_COUNT; + rowLock = true; + justification = JUSTIFICATION_LEFT; + windowStyleId = 0; + penStyleId = 0; + windowFillColor = COLOR_SOLID_BLACK; + + foregroundColor = COLOR_SOLID_WHITE; + backgroundColor = COLOR_SOLID_BLACK; + } + + public void clear() { + rolledUpCaptions.clear(); + captionStringBuilder.clear(); + italicsStartPosition = C.POSITION_UNSET; + underlineStartPosition = C.POSITION_UNSET; + foregroundColorStartPosition = C.POSITION_UNSET; + backgroundColorStartPosition = C.POSITION_UNSET; + } + + public boolean isDefined() { + return defined; + } + + public void setVisibility(boolean visible) { + this.visible = visible; + } + + public boolean isVisible() { + return visible; + } + + public void defineWindow(boolean visible, boolean rowLock, boolean columnLock, int priority, + boolean relativePositioning, int verticalAnchor, int horizontalAnchor, int rowCount, + int columnCount, int anchorId, int windowStyleId, int penStyleId) { + this.defined = true; + this.visible = visible; + this.rowLock = rowLock; + this.priority = priority; + this.relativePositioning = relativePositioning; + this.verticalAnchor = verticalAnchor; + this.horizontalAnchor = horizontalAnchor; + this.anchorId = anchorId; + + // Decoders must add one to rowCount to get the desired number of rows. + if (this.rowCount != rowCount + 1) { + this.rowCount = rowCount + 1; + + // Trim any rolled up captions that are no longer valid, if applicable. + while ((rowLock && (rolledUpCaptions.size() >= this.rowCount)) + || (rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT)) { + rolledUpCaptions.remove(0); + } + } + + // TODO: Add support for column lock and count. + + if (windowStyleId != 0 && this.windowStyleId != windowStyleId) { + this.windowStyleId = windowStyleId; + // windowStyleId is 1-based. + int windowStyleIdIndex = windowStyleId - 1; + // Note that Border type and border color are the same for all window styles. + setWindowAttributes(WINDOW_STYLE_FILL[windowStyleIdIndex], COLOR_TRANSPARENT, + WINDOW_STYLE_WORD_WRAP[windowStyleIdIndex], BORDER_AND_EDGE_TYPE_NONE, + WINDOW_STYLE_PRINT_DIRECTION[windowStyleIdIndex], + WINDOW_STYLE_SCROLL_DIRECTION[windowStyleIdIndex], + WINDOW_STYLE_JUSTIFICATION[windowStyleIdIndex]); + } + + if (penStyleId != 0 && this.penStyleId != penStyleId) { + this.penStyleId = penStyleId; + // penStyleId is 1-based. + int penStyleIdIndex = penStyleId - 1; + // Note that pen size, offset, italics, underline, foreground color, and foreground + // opacity are the same for all pen styles. + setPenAttributes(0, PEN_OFFSET_NORMAL, PEN_SIZE_STANDARD, false, false, + PEN_STYLE_EDGE_TYPE[penStyleIdIndex], PEN_STYLE_FONT_STYLE[penStyleIdIndex]); + setPenColor(COLOR_SOLID_WHITE, PEN_STYLE_BACKGROUND[penStyleIdIndex], COLOR_SOLID_BLACK); + } + } + + + public void setWindowAttributes(int fillColor, int borderColor, boolean wordWrapToggle, + int borderType, int printDirection, int scrollDirection, int justification) { + this.windowFillColor = fillColor; + // TODO: Add support for border color and types. + // TODO: Add support for word wrap. + // TODO: Add support for other scroll directions. + // TODO: Add support for other print directions. + this.justification = justification; + + } + + public void setPenAttributes(int textTag, int offset, int penSize, boolean italicsToggle, + boolean underlineToggle, int edgeType, int fontStyle) { + // TODO: Add support for text tags. + // TODO: Add support for other offsets. + // TODO: Add support for other pen sizes. + + if (italicsStartPosition != C.POSITION_UNSET) { + if (!italicsToggle) { + captionStringBuilder.setSpan(new StyleSpan(Typeface.ITALIC), italicsStartPosition, + captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + italicsStartPosition = C.POSITION_UNSET; + } + } else if (italicsToggle) { + italicsStartPosition = captionStringBuilder.length(); + } + + if (underlineStartPosition != C.POSITION_UNSET) { + if (!underlineToggle) { + captionStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition, + captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + underlineStartPosition = C.POSITION_UNSET; + } + } else if (underlineToggle) { + underlineStartPosition = captionStringBuilder.length(); + } + + // TODO: Add support for edge types. + // TODO: Add support for other font styles. + } + + public void setPenColor(int foregroundColor, int backgroundColor, int edgeColor) { + if (foregroundColorStartPosition != C.POSITION_UNSET) { + if (this.foregroundColor != foregroundColor) { + captionStringBuilder.setSpan(new ForegroundColorSpan(this.foregroundColor), + foregroundColorStartPosition, captionStringBuilder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + if (foregroundColor != COLOR_SOLID_WHITE) { + foregroundColorStartPosition = captionStringBuilder.length(); + this.foregroundColor = foregroundColor; + } + + if (backgroundColorStartPosition != C.POSITION_UNSET) { + if (this.backgroundColor != backgroundColor) { + captionStringBuilder.setSpan(new BackgroundColorSpan(this.backgroundColor), + backgroundColorStartPosition, captionStringBuilder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + if (backgroundColor != COLOR_SOLID_BLACK) { + backgroundColorStartPosition = captionStringBuilder.length(); + this.backgroundColor = backgroundColor; + } + + // TODO: Add support for edge color. + } + + public void setPenLocation(int row, int column) { + // TODO: Support moving the pen location with a window. + } + + public void backspace() { + int length = captionStringBuilder.length(); + if (length > 0) { + captionStringBuilder.delete(length - 1, length); + } + } + + public void append(char text) { + if (text == '\n') { + rolledUpCaptions.add(buildSpannableString()); + captionStringBuilder.clear(); + + if (italicsStartPosition != C.POSITION_UNSET) { + italicsStartPosition = 0; + } + if (underlineStartPosition != C.POSITION_UNSET) { + underlineStartPosition = 0; + } + if (foregroundColorStartPosition != C.POSITION_UNSET) { + foregroundColorStartPosition = 0; + } + if (backgroundColorStartPosition != C.POSITION_UNSET) { + backgroundColorStartPosition = 0; + } + + while ((rowLock && (rolledUpCaptions.size() >= rowCount)) + || (rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT)) { + rolledUpCaptions.remove(0); + } + } else { + captionStringBuilder.append(text); + } + } + + public SpannableString buildSpannableString() { + SpannableStringBuilder spannableStringBuilder = + new SpannableStringBuilder(captionStringBuilder); + int length = spannableStringBuilder.length(); + + if (length > 0) { + if (italicsStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new StyleSpan(Typeface.ITALIC), italicsStartPosition, + length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (underlineStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition, + length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (foregroundColorStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new ForegroundColorSpan(foregroundColor), + foregroundColorStartPosition, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (backgroundColorStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new BackgroundColorSpan(backgroundColor), + backgroundColorStartPosition, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + return new SpannableString(spannableStringBuilder); + } + + public Cea708Cue build() { + if (isEmpty()) { + // The cue is empty. + return null; + } + + SpannableStringBuilder cueString = new SpannableStringBuilder(); + + // Add any rolled up captions, separated by new lines. + for (int i = 0; i < rolledUpCaptions.size(); i++) { + cueString.append(rolledUpCaptions.get(i)); + cueString.append('\n'); + } + // Add the current line. + cueString.append(buildSpannableString()); + + // TODO: Add support for right-to-left languages (i.e. where right would correspond to normal + // alignment). + Alignment alignment; + switch (justification) { + case JUSTIFICATION_FULL: + // TODO: Add support for full justification. + case JUSTIFICATION_LEFT: + alignment = Alignment.ALIGN_NORMAL; + break; + case JUSTIFICATION_RIGHT: + alignment = Alignment.ALIGN_OPPOSITE; + break; + case JUSTIFICATION_CENTER: + alignment = Alignment.ALIGN_CENTER; + break; + default: + throw new IllegalArgumentException("Unexpected justification value: " + justification); + } + + float position; + float line; + if (relativePositioning) { + position = (float) horizontalAnchor / RELATIVE_CUE_SIZE; + line = (float) verticalAnchor / RELATIVE_CUE_SIZE; + } else { + position = (float) horizontalAnchor / HORIZONTAL_SIZE; + line = (float) verticalAnchor / VERTICAL_SIZE; + } + // Apply screen-edge padding to the line and position. + position = (position * 0.9f) + 0.05f; + line = (line * 0.9f) + 0.05f; + + // anchorId specifies where the anchor should be placed on the caption cue/window. The 9 + // possible configurations are as follows: + // 0-----1-----2 + // | | + // 3 4 5 + // | | + // 6-----7-----8 + @AnchorType int verticalAnchorType; + if (anchorId % 3 == 0) { + verticalAnchorType = Cue.ANCHOR_TYPE_START; + } else if (anchorId % 3 == 1) { + verticalAnchorType = Cue.ANCHOR_TYPE_MIDDLE; + } else { + verticalAnchorType = Cue.ANCHOR_TYPE_END; + } + // TODO: Add support for right-to-left languages (i.e. where start is on the right). + @AnchorType int horizontalAnchorType; + if (anchorId / 3 == 0) { + horizontalAnchorType = Cue.ANCHOR_TYPE_START; + } else if (anchorId / 3 == 1) { + horizontalAnchorType = Cue.ANCHOR_TYPE_MIDDLE; + } else { + horizontalAnchorType = Cue.ANCHOR_TYPE_END; + } + + boolean windowColorSet = (windowFillColor != COLOR_SOLID_BLACK); + + return new Cea708Cue(cueString, alignment, line, Cue.LINE_TYPE_FRACTION, verticalAnchorType, + position, horizontalAnchorType, Cue.DIMEN_UNSET, windowColorSet, windowFillColor, + priority); + } + + public static int getArgbColorFromCeaColor(int red, int green, int blue) { + return getArgbColorFromCeaColor(red, green, blue, 0); + } + + public static int getArgbColorFromCeaColor(int red, int green, int blue, int opacity) { + Assertions.checkIndex(red, 0, 4); + Assertions.checkIndex(green, 0, 4); + Assertions.checkIndex(blue, 0, 4); + Assertions.checkIndex(opacity, 0, 4); + + int alpha; + switch (opacity) { + case 0: + case 1: + // Note the value of '1' is actually FLASH, but we don't support that. + alpha = 255; + break; + case 2: + alpha = 127; + break; + case 3: + alpha = 0; + break; + default: + alpha = 255; + } + + // TODO: Add support for the Alternative Minimum Color List or the full 64 RGB combinations. + + // Return values based on the Minimum Color List + return Color.argb(alpha, + (red > 1 ? 255 : 0), + (green > 1 ? 255 : 0), + (blue > 1 ? 255 : 0)); + } + + } + +} From 877c7f4e30ae23401d5856ff2e41a44ea6413b98 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 18 Jan 2017 02:51:14 -0800 Subject: [PATCH 76/92] Some misc file rearrangement. - Move .graffle files out of third_party - Add logo .ai file - Remove logo .svg files ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144812776 --- demo/assets/ic_launcher.svg | 660 ------------------------------------ 1 file changed, 660 deletions(-) delete mode 100644 demo/assets/ic_launcher.svg diff --git a/demo/assets/ic_launcher.svg b/demo/assets/ic_launcher.svg deleted file mode 100644 index 5486b27e29..0000000000 --- a/demo/assets/ic_launcher.svg +++ /dev/null @@ -1,660 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From bc4dc591f560f9c829eb1dd07a8ca62e8af452aa Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 18 Jan 2017 03:22:04 -0800 Subject: [PATCH 77/92] Fix some style nits in ID3 chapter support. Issue: #2316 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144815010 --- ...rameTest.java => ChapterTocFrameTest.java} | 12 ++-- .../exoplayer2/metadata/id3/ChapterFrame.java | 69 ++++++++++++------- ...pterTOCFrame.java => ChapterTocFrame.java} | 36 ++++++---- .../exoplayer2/metadata/id3/Id3Decoder.java | 19 +++-- .../exoplayer2/metadata/id3/UrlLinkFrame.java | 1 - 5 files changed, 88 insertions(+), 49 deletions(-) rename library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/{ChapterTOCFrameTest.java => ChapterTocFrameTest.java} (77%) rename library/src/main/java/com/google/android/exoplayer2/metadata/id3/{ChapterTOCFrame.java => ChapterTocFrame.java} (77%) diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTOCFrameTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrameTest.java similarity index 77% rename from library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTOCFrameTest.java rename to library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrameTest.java index b0819ff427..9641de7669 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTOCFrameTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrameTest.java @@ -19,9 +19,9 @@ import android.os.Parcel; import junit.framework.TestCase; /** - * Test for {@link ChapterTOCFrame}. + * Test for {@link ChapterTocFrame}. */ -public final class ChapterTOCFrameTest extends TestCase { +public final class ChapterTocFrameTest extends TestCase { public void testParcelable() { String[] children = new String[] {"child0", "child1"}; @@ -29,15 +29,15 @@ public final class ChapterTOCFrameTest extends TestCase { new TextInformationFrame("TIT2", null, "title"), new UrlLinkFrame("WXXX", "description", "url") }; - ChapterTOCFrame chapterTOCFrameToParcel = new ChapterTOCFrame("id", false, true, children, + ChapterTocFrame chapterTocFrameToParcel = new ChapterTocFrame("id", false, true, children, subFrames); Parcel parcel = Parcel.obtain(); - chapterTOCFrameToParcel.writeToParcel(parcel, 0); + chapterTocFrameToParcel.writeToParcel(parcel, 0); parcel.setDataPosition(0); - ChapterTOCFrame chapterTOCFrameFromParcel = ChapterTOCFrame.CREATOR.createFromParcel(parcel); - assertEquals(chapterTOCFrameToParcel, chapterTOCFrameFromParcel); + ChapterTocFrame chapterTocFrameFromParcel = ChapterTocFrame.CREATOR.createFromParcel(parcel); + assertEquals(chapterTocFrameToParcel, chapterTocFrameFromParcel); parcel.recycle(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java index 22fd0d5fe4..c82f982aa7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.metadata.id3; import android.os.Parcel; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; import java.util.Arrays; @@ -27,18 +28,24 @@ public final class ChapterFrame extends Id3Frame { public static final String ID = "CHAP"; public final String chapterId; - public final int startTime; - public final int endTime; - public final int startOffset; - public final int endOffset; + public final int startTimeMs; + public final int endTimeMs; + /** + * The byte offset of the start of the chapter, or {@link C#POSITION_UNSET} if not set. + */ + public final long startOffset; + /** + * The byte offset of the end of the chapter, or {@link C#POSITION_UNSET} if not set. + */ + public final long endOffset; private final Id3Frame[] subFrames; - public ChapterFrame(String chapterId, int startTime, int endTime, int startOffset, int endOffset, - Id3Frame[] subFrames) { + public ChapterFrame(String chapterId, int startTimeMs, int endTimeMs, long startOffset, + long endOffset, Id3Frame[] subFrames) { super(ID); this.chapterId = chapterId; - this.startTime = startTime; - this.endTime = endTime; + this.startTimeMs = startTimeMs; + this.endTimeMs = endTimeMs; this.startOffset = startOffset; this.endOffset = endOffset; this.subFrames = subFrames; @@ -47,10 +54,10 @@ public final class ChapterFrame extends Id3Frame { /* package */ ChapterFrame(Parcel in) { super(ID); this.chapterId = in.readString(); - this.startTime = in.readInt(); - this.endTime = in.readInt(); - this.startOffset = in.readInt(); - this.endOffset = in.readInt(); + this.startTimeMs = in.readInt(); + this.endTimeMs = in.readInt(); + this.startOffset = in.readLong(); + this.endOffset = in.readLong(); int subFrameCount = in.readInt(); subFrames = new Id3Frame[subFrameCount]; for (int i = 0; i < subFrameCount; i++) { @@ -58,6 +65,20 @@ public final class ChapterFrame extends Id3Frame { } } + /** + * Returns the number of sub-frames. + */ + public int getSubFrameCount() { + return subFrames.length; + } + + /** + * Returns the sub-frame at {@code index}. + */ + public Id3Frame getSubFrame(int index) { + return subFrames[index]; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -67,8 +88,8 @@ public final class ChapterFrame extends Id3Frame { return false; } ChapterFrame other = (ChapterFrame) obj; - return startTime == other.startTime - && endTime == other.endTime + return startTimeMs == other.startTimeMs + && endTimeMs == other.endTimeMs && startOffset == other.startOffset && endOffset == other.endOffset && Util.areEqual(chapterId, other.chapterId) @@ -78,10 +99,10 @@ public final class ChapterFrame extends Id3Frame { @Override public int hashCode() { int result = 17; - result = 31 * result + startTime; - result = 31 * result + endTime; - result = 31 * result + startOffset; - result = 31 * result + endOffset; + result = 31 * result + startTimeMs; + result = 31 * result + endTimeMs; + result = 31 * result + (int) startOffset; + result = 31 * result + (int) endOffset; result = 31 * result + (chapterId != null ? chapterId.hashCode() : 0); return result; } @@ -89,13 +110,13 @@ public final class ChapterFrame extends Id3Frame { @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(chapterId); - dest.writeInt(startTime); - dest.writeInt(endTime); - dest.writeInt(startOffset); - dest.writeInt(endOffset); + dest.writeInt(startTimeMs); + dest.writeInt(endTimeMs); + dest.writeLong(startOffset); + dest.writeLong(endOffset); dest.writeInt(subFrames.length); - for (int i = 0; i < subFrames.length; i++) { - dest.writeParcelable(subFrames[i], 0); + for (Id3Frame subFrame : subFrames) { + dest.writeParcelable(subFrame, 0); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTOCFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java similarity index 77% rename from library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTOCFrame.java rename to library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java index 6dfcf9f104..d71d0863c7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTOCFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java @@ -16,15 +16,13 @@ package com.google.android.exoplayer2.metadata.id3; import android.os.Parcel; - import com.google.android.exoplayer2.util.Util; - import java.util.Arrays; /** * Chapter table of contents ID3 frame. */ -public final class ChapterTOCFrame extends Id3Frame { +public final class ChapterTocFrame extends Id3Frame { public static final String ID = "CTOC"; @@ -32,9 +30,9 @@ public final class ChapterTOCFrame extends Id3Frame { public final boolean isRoot; public final boolean isOrdered; public final String[] children; - public final Id3Frame[] subFrames; + private final Id3Frame[] subFrames; - public ChapterTOCFrame(String elementId, boolean isRoot, boolean isOrdered, String[] children, + public ChapterTocFrame(String elementId, boolean isRoot, boolean isOrdered, String[] children, Id3Frame[] subFrames) { super(ID); this.elementId = elementId; @@ -44,7 +42,7 @@ public final class ChapterTOCFrame extends Id3Frame { this.subFrames = subFrames; } - /* package */ ChapterTOCFrame(Parcel in) { + /* package */ ChapterTocFrame(Parcel in) { super(ID); this.elementId = in.readString(); this.isRoot = in.readByte() != 0; @@ -57,6 +55,20 @@ public final class ChapterTOCFrame extends Id3Frame { } } + /** + * Returns the number of sub-frames. + */ + public int getSubFrameCount() { + return subFrames.length; + } + + /** + * Returns the sub-frame at {@code index}. + */ + public Id3Frame getSubFrame(int index) { + return subFrames[index]; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -65,7 +77,7 @@ public final class ChapterTOCFrame extends Id3Frame { if (obj == null || getClass() != obj.getClass()) { return false; } - ChapterTOCFrame other = (ChapterTOCFrame) obj; + ChapterTocFrame other = (ChapterTocFrame) obj; return isRoot == other.isRoot && isOrdered == other.isOrdered && Util.areEqual(elementId, other.elementId) @@ -94,16 +106,16 @@ public final class ChapterTOCFrame extends Id3Frame { } } - public static final Creator CREATOR = new Creator() { + public static final Creator CREATOR = new Creator() { @Override - public ChapterTOCFrame createFromParcel(Parcel in) { - return new ChapterTOCFrame(in); + public ChapterTocFrame createFromParcel(Parcel in) { + return new ChapterTocFrame(in); } @Override - public ChapterTOCFrame[] newArray(int size) { - return new ChapterTOCFrame[size]; + public ChapterTocFrame[] newArray(int size) { + return new ChapterTocFrame[size]; } }; diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java index 266aad6f70..9c3aa03271 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.metadata.id3; import android.util.Log; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; @@ -368,8 +369,8 @@ public final class Id3Decoder implements MetadataDecoder { return new TextInformationFrame(id, null, value); } - private static UrlLinkFrame decodeWxxxFrame(ParsableByteArray id3Data, - int frameSize) throws UnsupportedEncodingException { + private static UrlLinkFrame decodeWxxxFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { int encoding = id3Data.readUnsignedByte(); String charset = getCharsetName(encoding); @@ -523,8 +524,14 @@ public final class Id3Decoder implements MetadataDecoder { int startTime = id3Data.readInt(); int endTime = id3Data.readInt(); - int startOffset = id3Data.readInt(); - int endOffset = id3Data.readInt(); + long startOffset = id3Data.readUnsignedInt(); + if (startOffset == 0xFFFFFFFFL) { + startOffset = C.POSITION_UNSET; + } + long endOffset = id3Data.readUnsignedInt(); + if (endOffset == 0xFFFFFFFFL) { + endOffset = C.POSITION_UNSET; + } ArrayList subFrames = new ArrayList<>(); int limit = framePosition + frameSize; @@ -541,7 +548,7 @@ public final class Id3Decoder implements MetadataDecoder { return new ChapterFrame(chapterId, startTime, endTime, startOffset, endOffset, subFrameArray); } - private static ChapterTOCFrame decodeChapterTOCFrame(ParsableByteArray id3Data, int frameSize, + private static ChapterTocFrame decodeChapterTOCFrame(ParsableByteArray id3Data, int frameSize, int majorVersion, boolean unsignedIntFrameSizeHack, int frameHeaderSize) throws UnsupportedEncodingException { int framePosition = id3Data.getPosition(); @@ -575,7 +582,7 @@ public final class Id3Decoder implements MetadataDecoder { Id3Frame[] subFrameArray = new Id3Frame[subFrames.size()]; subFrames.toArray(subFrameArray); - return new ChapterTOCFrame(elementId, isRoot, isOrdered, children, subFrameArray); + return new ChapterTocFrame(elementId, isRoot, isOrdered, children, subFrameArray); } private static BinaryFrame decodeBinaryFrame(ParsableByteArray id3Data, int frameSize, diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/UrlLinkFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/UrlLinkFrame.java index 7936d50b55..2148b921e1 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/UrlLinkFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/UrlLinkFrame.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.metadata.id3; import android.os.Parcel; import android.os.Parcelable; - import com.google.android.exoplayer2.util.Util; /** From 51f96374d46110438ec6ca2e0ba9bac4061299bd Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 18 Jan 2017 06:11:15 -0800 Subject: [PATCH 78/92] Make headers consisting across build.gradle files ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144826116 --- library/build.gradle | 4 ++-- testutils/build.gradle | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/library/build.gradle b/library/build.gradle index ae2eb2f3d9..0d4bbd0256 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -1,5 +1,3 @@ -import com.android.builder.core.BuilderConstants - // Copyright (C) 2016 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,6 +11,8 @@ import com.android.builder.core.BuilderConstants // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import com.android.builder.core.BuilderConstants + apply plugin: 'com.android.library' apply plugin: 'bintray-release' diff --git a/testutils/build.gradle b/testutils/build.gradle index b935b30c69..83ff065f9a 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -1,3 +1,16 @@ +// 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. apply plugin: 'com.android.library' android { From 4b957cce47e0a7eab92f4f02635a31c5110984bd Mon Sep 17 00:00:00 2001 From: eguven Date: Wed, 18 Jan 2017 09:28:17 -0800 Subject: [PATCH 79/92] Fix streaming license renew error When the first streaming license request response provided to mediaDrm it might return an empty array instead of null. This was set to offlineLicenseKeySetId which made the work like there is a valid offline license. Simplified the code and made it to set offlineLicenseKeySetId only if there is sensible data in keySetId. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144843144 --- .../android/exoplayer2/drm/DefaultDrmSessionManager.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/library/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index 6eb70428d5..9c959a38c5 100644 --- a/library/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java +++ b/library/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -564,9 +564,7 @@ public class DefaultDrmSessionManager implements DrmSe } } else { byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, (byte[]) response); - if (offlineLicenseKeySetId != null && (keySetId == null || keySetId.length == 0)) { - // This means that the keySetId is unchanged. - } else { + if (keySetId != null && keySetId.length != 0) { offlineLicenseKeySetId = keySetId; } state = STATE_OPENED_WITH_KEYS; From d9be650b3be2a7d8c6c61af43f04fe2d91de60e3 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 19 Jan 2017 06:45:31 -0800 Subject: [PATCH 80/92] DASH: Fix propagation of language from manifest Issue: #2335 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144956177 --- .../dash/manifest/DashManifestParser.java | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index 688d68e893..8bbf8f6ccf 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -343,8 +343,7 @@ public class DashManifestParser extends DefaultHandler return C.TRACK_TYPE_VIDEO; } else if (MimeTypes.isAudio(sampleMimeType)) { return C.TRACK_TYPE_AUDIO; - } else if (mimeTypeIsRawText(sampleMimeType) - || MimeTypes.APPLICATION_RAWCC.equals(format.containerMimeType)) { + } else if (mimeTypeIsRawText(sampleMimeType)) { return C.TRACK_TYPE_TEXT; } return C.TRACK_TYPE_UNKNOWN; @@ -501,8 +500,7 @@ public class DashManifestParser extends DefaultHandler } else if (MimeTypes.isAudio(sampleMimeType)) { return Format.createAudioContainerFormat(id, containerMimeType, sampleMimeType, codecs, bitrate, audioChannels, audioSamplingRate, null, selectionFlags, language); - } else if (mimeTypeIsRawText(sampleMimeType) - || MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) { + } else if (mimeTypeIsRawText(sampleMimeType)) { return Format.createTextContainerFormat(id, containerMimeType, sampleMimeType, codecs, bitrate, selectionFlags, language, accessiblityChannel); } @@ -731,6 +729,14 @@ public class DashManifestParser extends DefaultHandler return MimeTypes.getAudioMediaMimeType(codecs); } else if (MimeTypes.isVideo(containerMimeType)) { return MimeTypes.getVideoMediaMimeType(codecs); + } else if (mimeTypeIsRawText(containerMimeType)) { + return containerMimeType; + } else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) { + if ("stpp".equals(codecs)) { + return MimeTypes.APPLICATION_TTML; + } else if ("wvtt".equals(codecs)) { + return MimeTypes.APPLICATION_MP4VTT; + } } else if (MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) { if (codecs != null) { if (codecs.contains("cea708")) { @@ -740,14 +746,6 @@ public class DashManifestParser extends DefaultHandler } } return null; - } else if (mimeTypeIsRawText(containerMimeType)) { - return containerMimeType; - } else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) { - if ("stpp".equals(codecs)) { - return MimeTypes.APPLICATION_TTML; - } else if ("wvtt".equals(codecs)) { - return MimeTypes.APPLICATION_MP4VTT; - } } return null; } @@ -759,7 +757,11 @@ public class DashManifestParser extends DefaultHandler * @return Whether the mimeType is a text sample mimeType. */ private static boolean mimeTypeIsRawText(String mimeType) { - return MimeTypes.isText(mimeType) || MimeTypes.APPLICATION_TTML.equals(mimeType); + return MimeTypes.isText(mimeType) + || MimeTypes.APPLICATION_TTML.equals(mimeType) + || MimeTypes.APPLICATION_MP4VTT.equals(mimeType) + || MimeTypes.APPLICATION_CEA708.equals(mimeType) + || MimeTypes.APPLICATION_CEA608.equals(mimeType); } /** From ae01c1a6fd01302941677d8233e2afde601389d2 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 19 Jan 2017 08:59:42 -0800 Subject: [PATCH 81/92] Move inband event streams to Representation This is more consistent with our handling of DRM init data, and is more correct. It'll be up to whoever's using the manifest to look one layer deeper and figure out what event streams are defined on all representations, if they wish to do so. Issue #2176 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144968183 --- .../drm/OfflineLicenseHelperTest.java | 11 ++-- .../source/dash/manifest/AdaptationSet.java | 11 +--- .../dash/manifest/DashManifestParser.java | 44 ++++----------- .../source/dash/manifest/Representation.java | 53 +++++++++++++++---- 4 files changed, 59 insertions(+), 60 deletions(-) diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java index 0342e37bd6..c7ebb22d9a 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java @@ -27,14 +27,12 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; -import com.google.android.exoplayer2.source.dash.manifest.InbandEventStream; import com.google.android.exoplayer2.source.dash.manifest.Period; import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.HttpDataSource; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import org.mockito.Mock; @@ -205,18 +203,17 @@ public class OfflineLicenseHelperTest extends InstrumentationTestCase { private static DashManifest newDashManifestWithAllElements() { return newDashManifest(newPeriods(newAdaptationSets(newRepresentations(newDrmInitData())))); } - + private static DashManifest newDashManifest(Period... periods) { return new DashManifest(0, 0, 0, false, 0, 0, 0, null, null, Arrays.asList(periods)); } - + private static Period newPeriods(AdaptationSet... adaptationSets) { return new Period("", 0, Arrays.asList(adaptationSets)); } private static AdaptationSet newAdaptationSets(Representation... representations) { - return new AdaptationSet(0, C.TRACK_TYPE_VIDEO, Arrays.asList(representations), - Collections.emptyList()); + return new AdaptationSet(0, C.TRACK_TYPE_VIDEO, Arrays.asList(representations)); } private static Representation newRepresentations(DrmInitData drmInitData) { @@ -225,7 +222,7 @@ public class OfflineLicenseHelperTest extends InstrumentationTestCase { } private static DrmInitData newDrmInitData() { - return new DrmInitData(new SchemeData(C.WIDEVINE_UUID, "mimeType", + return new DrmInitData(new SchemeData(C.WIDEVINE_UUID, "mimeType", new byte[]{1, 4, 7, 0, 3, 6})); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java index 30649dcbe2..c4a4a4446b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java @@ -45,26 +45,17 @@ public class AdaptationSet { */ public final List representations; - /** - * The {@link InbandEventStream}s contained by all {@link Representation}s in the adaptation set. - */ - public final List inbandEventStreams; - /** * @param id A non-negative identifier for the adaptation set that's unique in the scope of its * containing period, or {@link #ID_UNSET} if not specified. * @param type The type of the adaptation set. One of the {@link com.google.android.exoplayer2.C} * {@code TRACK_TYPE_*} constants. * @param representations The {@link Representation}s in the adaptation set. - * @param inbandEventStreams The {@link InbandEventStream}s contained by all - * {@link Representation}s in the adaptation set. */ - public AdaptationSet(int id, int type, List representations, - List inbandEventStreams) { + public AdaptationSet(int id, int type, List representations) { this.id = id; this.type = type; this.representations = Collections.unmodifiableList(representations); - this.inbandEventStreams = Collections.unmodifiableList(inbandEventStreams); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index 8bbf8f6ccf..a9dc0a8665 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -240,9 +240,8 @@ public class DashManifestParser extends DefaultHandler String language = xpp.getAttributeValue(null, "lang"); int accessibilityChannel = Format.NO_VALUE; ArrayList drmSchemeDatas = new ArrayList<>(); + ArrayList inbandEventStreams = new ArrayList<>(); List representationInfos = new ArrayList<>(); - List adaptationSetInbandEventStreams = new ArrayList<>(); - List commonRepresentationInbandEventStreams = null; @C.SelectionFlags int selectionFlags = 0; boolean seenFirstBaseUrl = false; @@ -274,22 +273,6 @@ public class DashManifestParser extends DefaultHandler contentType = checkContentTypeConsistency(contentType, getContentType(representationInfo.format)); representationInfos.add(representationInfo); - // Initialize or update InbandEventStream elements defined in all child Representations. - List inbandEventStreams = representationInfo.inbandEventStreams; - if (commonRepresentationInbandEventStreams == null) { - // Initialize with the elements defined in this representation. - commonRepresentationInbandEventStreams = new ArrayList<>(inbandEventStreams); - } else { - // Remove elements that are not also defined in this representation. - for (int i = commonRepresentationInbandEventStreams.size() - 1; i >= 0; i--) { - InbandEventStream inbandEventStream = commonRepresentationInbandEventStreams.get(i); - if (!inbandEventStreams.contains(inbandEventStream)) { - Log.w(TAG, "Ignoring InbandEventStream element not defined on all Representations: " - + inbandEventStream); - commonRepresentationInbandEventStreams.remove(i); - } - } - } } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { @@ -297,33 +280,25 @@ public class DashManifestParser extends DefaultHandler } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { segmentBase = parseSegmentTemplate(xpp, (SegmentTemplate) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { - adaptationSetInbandEventStreams.add(parseInbandEventStream(xpp)); + inbandEventStreams.add(parseInbandEventStream(xpp)); } else if (XmlPullParserUtil.isStartTag(xpp)) { parseAdaptationSetChild(xpp); } } while (!XmlPullParserUtil.isEndTag(xpp, "AdaptationSet")); - // Pull up InbandEventStream elements defined in all child Representations. - for (int i = 0; i < commonRepresentationInbandEventStreams.size(); i++) { - InbandEventStream inbandEventStream = commonRepresentationInbandEventStreams.get(i); - if (!adaptationSetInbandEventStreams.contains(inbandEventStream)) { - adaptationSetInbandEventStreams.add(inbandEventStream); - } - } - // Build the representations. List representations = new ArrayList<>(representationInfos.size()); for (int i = 0; i < representationInfos.size(); i++) { representations.add(buildRepresentation(representationInfos.get(i), contentId, - drmSchemeDatas)); + drmSchemeDatas, inbandEventStreams)); } - return buildAdaptationSet(id, contentType, representations, adaptationSetInbandEventStreams); + return buildAdaptationSet(id, contentType, representations); } protected AdaptationSet buildAdaptationSet(int id, int contentType, - List representations, List inbandEventStreams) { - return new AdaptationSet(id, contentType, representations, inbandEventStreams); + List representations) { + return new AdaptationSet(id, contentType, representations); } protected int parseContentType(XmlPullParser xpp) { @@ -510,15 +485,18 @@ public class DashManifestParser extends DefaultHandler } protected Representation buildRepresentation(RepresentationInfo representationInfo, - String contentId, ArrayList extraDrmSchemeDatas) { + String contentId, ArrayList extraDrmSchemeDatas, + ArrayList extraInbandEventStreams) { Format format = representationInfo.format; ArrayList drmSchemeDatas = representationInfo.drmSchemeDatas; drmSchemeDatas.addAll(extraDrmSchemeDatas); if (!drmSchemeDatas.isEmpty()) { format = format.copyWithDrmInitData(new DrmInitData(drmSchemeDatas)); } + ArrayList inbandEventStremas = representationInfo.inbandEventStreams; + inbandEventStremas.addAll(extraInbandEventStreams); return Representation.newInstance(contentId, Representation.REVISION_ID_DEFAULT, format, - representationInfo.baseUrl, representationInfo.segmentBase); + representationInfo.baseUrl, representationInfo.segmentBase, inbandEventStremas); } // SegmentBase, SegmentList and SegmentTemplate parsing. diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java index f52727c1a8..cdf84f5f71 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java @@ -21,6 +21,8 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.MultiSegmentBase; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase; +import java.util.Collections; +import java.util.List; /** * A DASH representation. @@ -60,6 +62,10 @@ public abstract class Representation { * The offset of the presentation timestamps in the media stream relative to media time. */ public final long presentationTimeOffsetUs; + /** + * The {@link InbandEventStream}s in the representation. Never null, but may be empty. + */ + public final List inbandEventStreams; private final RangedUri initializationUri; @@ -78,6 +84,23 @@ public abstract class Representation { return newInstance(contentId, revisionId, format, baseUrl, segmentBase, null); } + /** + * Constructs a new instance. + * + * @param contentId Identifies the piece of content to which this representation belongs. + * @param revisionId Identifies the revision of the content. + * @param format The format of the representation. + * @param baseUrl The base URL. + * @param segmentBase A segment base element for the representation. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. + * @return The constructed instance. + */ + public static Representation newInstance(String contentId, long revisionId, Format format, + String baseUrl, SegmentBase segmentBase, List inbandEventStreams) { + return newInstance(contentId, revisionId, format, baseUrl, segmentBase, inbandEventStreams, + null); + } + /** * Constructs a new instance. * @@ -86,18 +109,20 @@ public abstract class Representation { * @param format The format of the representation. * @param baseUrl The base URL of the representation. * @param segmentBase A segment base element for the representation. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. * @param customCacheKey A custom value to be returned from {@link #getCacheKey()}, or null. This * parameter is ignored if {@code segmentBase} consists of multiple segments. * @return The constructed instance. */ public static Representation newInstance(String contentId, long revisionId, Format format, - String baseUrl, SegmentBase segmentBase, String customCacheKey) { + String baseUrl, SegmentBase segmentBase, List inbandEventStreams, + String customCacheKey) { if (segmentBase instanceof SingleSegmentBase) { return new SingleSegmentRepresentation(contentId, revisionId, format, baseUrl, - (SingleSegmentBase) segmentBase, customCacheKey, C.LENGTH_UNSET); + (SingleSegmentBase) segmentBase, inbandEventStreams, customCacheKey, C.LENGTH_UNSET); } else if (segmentBase instanceof MultiSegmentBase) { return new MultiSegmentRepresentation(contentId, revisionId, format, baseUrl, - (MultiSegmentBase) segmentBase); + (MultiSegmentBase) segmentBase, inbandEventStreams); } else { throw new IllegalArgumentException("segmentBase must be of type SingleSegmentBase or " + "MultiSegmentBase"); @@ -105,11 +130,14 @@ public abstract class Representation { } private Representation(String contentId, long revisionId, Format format, String baseUrl, - SegmentBase segmentBase) { + SegmentBase segmentBase, List inbandEventStreams) { this.contentId = contentId; this.revisionId = revisionId; this.format = format; this.baseUrl = baseUrl; + this.inbandEventStreams = inbandEventStreams == null + ? Collections.emptyList() + : Collections.unmodifiableList(inbandEventStreams); initializationUri = segmentBase.getInitialization(this); presentationTimeOffsetUs = segmentBase.getPresentationTimeOffsetUs(); } @@ -167,18 +195,20 @@ public abstract class Representation { * @param initializationEnd The offset of the last byte of initialization data. * @param indexStart The offset of the first byte of index data. * @param indexEnd The offset of the last byte of index data. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. * @param customCacheKey A custom value to be returned from {@link #getCacheKey()}, or null. * @param contentLength The content length, or {@link C#LENGTH_UNSET} if unknown. */ public static SingleSegmentRepresentation newInstance(String contentId, long revisionId, Format format, String uri, long initializationStart, long initializationEnd, - long indexStart, long indexEnd, String customCacheKey, long contentLength) { + long indexStart, long indexEnd, List inbandEventStreams, + String customCacheKey, long contentLength) { RangedUri rangedUri = new RangedUri(null, initializationStart, initializationEnd - initializationStart + 1); SingleSegmentBase segmentBase = new SingleSegmentBase(rangedUri, 1, 0, indexStart, indexEnd - indexStart + 1); return new SingleSegmentRepresentation(contentId, revisionId, - format, uri, segmentBase, customCacheKey, contentLength); + format, uri, segmentBase, inbandEventStreams, customCacheKey, contentLength); } /** @@ -187,12 +217,14 @@ public abstract class Representation { * @param format The format of the representation. * @param baseUrl The base URL of the representation. * @param segmentBase The segment base underlying the representation. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. * @param customCacheKey A custom value to be returned from {@link #getCacheKey()}, or null. * @param contentLength The content length, or {@link C#LENGTH_UNSET} if unknown. */ public SingleSegmentRepresentation(String contentId, long revisionId, Format format, - String baseUrl, SingleSegmentBase segmentBase, String customCacheKey, long contentLength) { - super(contentId, revisionId, format, baseUrl, segmentBase); + String baseUrl, SingleSegmentBase segmentBase, List inbandEventStreams, + String customCacheKey, long contentLength) { + super(contentId, revisionId, format, baseUrl, segmentBase, inbandEventStreams); this.uri = Uri.parse(baseUrl); this.indexUri = segmentBase.getIndex(); this.cacheKey = customCacheKey != null ? customCacheKey @@ -235,10 +267,11 @@ public abstract class Representation { * @param format The format of the representation. * @param baseUrl The base URL of the representation. * @param segmentBase The segment base underlying the representation. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. */ public MultiSegmentRepresentation(String contentId, long revisionId, Format format, - String baseUrl, MultiSegmentBase segmentBase) { - super(contentId, revisionId, format, baseUrl, segmentBase); + String baseUrl, MultiSegmentBase segmentBase, List inbandEventStreams) { + super(contentId, revisionId, format, baseUrl, segmentBase, inbandEventStreams); this.segmentBase = segmentBase; } From 430d8e8a7a376545649e0de5f4aff4d2cc8c1d88 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 19 Jan 2017 09:14:50 -0800 Subject: [PATCH 82/92] Rename SingleTrackMetadataOutput ahead of real metadata support Issue #2176 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144969838 --- .../source/chunk/ChunkExtractorWrapper.java | 22 +++++++++---------- .../source/chunk/ContainerMediaChunk.java | 6 ++--- .../source/chunk/InitializationChunk.java | 6 ++--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java index ed76a505ea..9e3e5fb8c0 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java @@ -30,15 +30,15 @@ import java.io.IOException; /** * An {@link Extractor} wrapper for loading chunks containing a single track. *

    - * The wrapper allows switching of the {@link SingleTrackMetadataOutput} and {@link TrackOutput} - * which receive parsed data. + * The wrapper allows switching of the {@link SeekMapOutput} and {@link TrackOutput} that receive + * parsed data. */ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput { /** - * Receives metadata associated with the track as extracted by the wrapped {@link Extractor}. + * Receives {@link SeekMap}s extracted by the wrapped {@link Extractor}. */ - public interface SingleTrackMetadataOutput { + public interface SeekMapOutput { /** * @see ExtractorOutput#seekMap(SeekMap) @@ -53,7 +53,7 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput private final boolean resendFormatOnInit; private boolean extractorInitialized; - private SingleTrackMetadataOutput metadataOutput; + private SeekMapOutput seekMapOutput; private TrackOutput trackOutput; private Format sentFormat; @@ -68,7 +68,7 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput * @param preferManifestDrmInitData Whether {@link DrmInitData} defined in {@code manifestFormat} * should be preferred when the sample and manifest {@link Format}s are merged. * @param resendFormatOnInit Whether the extractor should resend the previous {@link Format} when - * it is initialized via {@link #init(SingleTrackMetadataOutput, TrackOutput)}. + * it is initialized via {@link #init(SeekMapOutput, TrackOutput)}. */ public ChunkExtractorWrapper(Extractor extractor, Format manifestFormat, boolean preferManifestDrmInitData, boolean resendFormatOnInit) { @@ -79,14 +79,14 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } /** - * Initializes the extractor to output to the provided {@link SingleTrackMetadataOutput} and + * Initializes the extractor to output to the provided {@link SeekMapOutput} and * {@link TrackOutput} instances, and configures it to receive data from a new chunk. * - * @param metadataOutput The {@link SingleTrackMetadataOutput} that will receive metadata. + * @param seekMapOutput The {@link SeekMapOutput} that will receive extracted {@link SeekMap}s. * @param trackOutput The {@link TrackOutput} that will receive sample data. */ - public void init(SingleTrackMetadataOutput metadataOutput, TrackOutput trackOutput) { - this.metadataOutput = metadataOutput; + public void init(SeekMapOutput seekMapOutput, TrackOutput trackOutput) { + this.seekMapOutput = seekMapOutput; this.trackOutput = trackOutput; if (!extractorInitialized) { extractor.init(this); @@ -130,7 +130,7 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput @Override public void seekMap(SeekMap seekMap) { - metadataOutput.seekMap(seekMap); + seekMapOutput.seekMap(seekMap); } // TrackOutput implementation. diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index 410990b2c1..5f2b843510 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -21,7 +21,7 @@ import com.google.android.exoplayer2.extractor.DefaultTrackOutput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.SeekMap; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SingleTrackMetadataOutput; +import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.Util; @@ -30,7 +30,7 @@ import java.io.IOException; /** * A {@link BaseMediaChunk} that uses an {@link Extractor} to decode sample data. */ -public class ContainerMediaChunk extends BaseMediaChunk implements SingleTrackMetadataOutput { +public class ContainerMediaChunk extends BaseMediaChunk implements SeekMapOutput { private final int chunkCount; private final long sampleOffsetUs; @@ -85,7 +85,7 @@ public class ContainerMediaChunk extends BaseMediaChunk implements SingleTrackMe return bytesLoaded; } - // SingleTrackMetadataOutput implementation. + // SeekMapOutput implementation. @Override public final void seekMap(SeekMap seekMap) { diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java index dd62a2b49b..c7ac2d66a9 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -22,7 +22,7 @@ import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SingleTrackMetadataOutput; +import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -32,7 +32,7 @@ import java.io.IOException; /** * A {@link Chunk} that uses an {@link Extractor} to decode initialization data for single track. */ -public final class InitializationChunk extends Chunk implements SingleTrackMetadataOutput, +public final class InitializationChunk extends Chunk implements SeekMapOutput, TrackOutput { private final ChunkExtractorWrapper extractorWrapper; @@ -85,7 +85,7 @@ public final class InitializationChunk extends Chunk implements SingleTrackMetad return seekMap; } - // SingleTrackMetadataOutput implementation. + // SeekMapOutput implementation. @Override public void seekMap(SeekMap seekMap) { From e3b3c8b69ca641ac4d8827ca8b65eb594692a158 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 19 Jan 2017 13:05:07 -0800 Subject: [PATCH 83/92] Display EMSG metadata events in EventLogger Issue #2176 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144998826 --- .../java/com/google/android/exoplayer2/demo/EventLogger.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java b/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java index d3e4b1ae3e..edc268ddb9 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataRenderer; +import com.google.android.exoplayer2.metadata.emsg.EventMessage; import com.google.android.exoplayer2.metadata.id3.ApicFrame; import com.google.android.exoplayer2.metadata.id3.CommentFrame; import com.google.android.exoplayer2.metadata.id3.GeobFrame; @@ -384,6 +385,10 @@ import java.util.Locale; } else if (entry instanceof Id3Frame) { Id3Frame id3Frame = (Id3Frame) entry; Log.d(TAG, prefix + String.format("%s", id3Frame.id)); + } else if (entry instanceof EventMessage) { + EventMessage eventMessage = (EventMessage) entry; + Log.d(TAG, prefix + String.format("EMSG: scheme=%s, id=%d, value=%s", + eventMessage.schemeIdUri, eventMessage.id, eventMessage.value)); } } } From 641597d7086a16f9ebcb7e69094c04990c1108bc Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 19 Jan 2017 13:09:05 -0800 Subject: [PATCH 84/92] Add a flag to enable EMSG output from FMP4 extractor Issue #2176 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144999302 --- .../extractor/mp4/FragmentedMp4Extractor.java | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index d1f47d981f..603aec4b22 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -20,6 +20,7 @@ import android.util.Log; import android.util.Pair; import android.util.SparseArray; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; @@ -74,7 +75,7 @@ public final class FragmentedMp4Extractor implements Extractor { */ @Retention(RetentionPolicy.SOURCE) @IntDef(flag = true, value = {FLAG_WORKAROUND_EVERY_VIDEO_FRAME_IS_SYNC_FRAME, - FLAG_WORKAROUND_IGNORE_TFDT_BOX, FLAG_SIDELOADED}) + FLAG_WORKAROUND_IGNORE_TFDT_BOX, FLAG_ENABLE_EMSG_TRACK, FLAG_SIDELOADED}) public @interface Flags {} /** * Flag to work around an issue in some video streams where every frame is marked as a sync frame. @@ -88,11 +89,16 @@ public final class FragmentedMp4Extractor implements Extractor { * Flag to ignore any tfdt boxes in the stream. */ public static final int FLAG_WORKAROUND_IGNORE_TFDT_BOX = 2; + /** + * Flag to indicate that the extractor should output an event message metadata track. Any event + * messages in the stream will be delivered as samples to this track. + */ + public static final int FLAG_ENABLE_EMSG_TRACK = 4; /** * Flag to indicate that the {@link Track} was sideloaded, instead of being declared by the MP4 * container. */ - private static final int FLAG_SIDELOADED = 4; + private static final int FLAG_SIDELOADED = 8; private static final byte[] PIFF_SAMPLE_ENCRYPTION_BOX_EXTENDED_TYPE = new byte[] {-94, 57, 79, 82, 90, -101, 79, 20, -94, 68, 108, 66, 124, 100, -115, -12}; @@ -143,7 +149,7 @@ public final class FragmentedMp4Extractor implements Extractor { // Extractor output. private ExtractorOutput extractorOutput; - private TrackOutput metadataTrackOutput; + private TrackOutput eventMessageTrackOutput; // Whether extractorOutput.seekMap has been called. private boolean haveOutputSeekMap; @@ -196,6 +202,7 @@ public final class FragmentedMp4Extractor implements Extractor { TrackBundle bundle = new TrackBundle(output.track(0)); bundle.init(sideloadedTrack, new DefaultSampleValues(0, 0, 0, 0)); trackBundles.put(0, bundle); + maybeInitEventMessageTrack(); extractorOutput.endTracks(); } } @@ -406,6 +413,7 @@ public final class FragmentedMp4Extractor implements Extractor { trackBundles.put(track.id, new TrackBundle(extractorOutput.track(i))); durationUs = Math.max(durationUs, track.durationUs); } + maybeInitEventMessageTrack(); extractorOutput.endTracks(); } else { Assertions.checkState(trackBundles.size() == trackCount); @@ -429,12 +437,20 @@ public final class FragmentedMp4Extractor implements Extractor { } } + private void maybeInitEventMessageTrack() { + if ((flags & FLAG_ENABLE_EMSG_TRACK) == 0) { + return; + } + eventMessageTrackOutput = extractorOutput.track(trackBundles.size()); + eventMessageTrackOutput.format(Format.createSampleFormat(null, MimeTypes.APPLICATION_EMSG, + Format.OFFSET_SAMPLE_RELATIVE)); + } + /** * Handles an emsg atom (defined in 23009-1). */ private void onEmsgLeafAtomRead(ParsableByteArray atom) { - // TODO: Enable metadata output. - if (metadataTrackOutput == null) { + if (eventMessageTrackOutput == null) { return; } // Parse the event's presentation time delta. @@ -447,11 +463,11 @@ public final class FragmentedMp4Extractor implements Extractor { // Output the sample data. atom.setPosition(Atom.FULL_HEADER_SIZE); int sampleSize = atom.bytesLeft(); - metadataTrackOutput.sampleData(atom, sampleSize); + eventMessageTrackOutput.sampleData(atom, sampleSize); // Output the sample metadata. if (segmentIndexEarliestPresentationTimeUs != C.TIME_UNSET) { // We can output the sample metadata immediately. - metadataTrackOutput.sampleMetadata( + eventMessageTrackOutput.sampleMetadata( segmentIndexEarliestPresentationTimeUs + presentationTimeDeltaUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0 /* offset */, null); } else { @@ -673,7 +689,7 @@ public final class FragmentedMp4Extractor implements Extractor { DefaultSampleValues defaultSampleValues = trackBundle.defaultSampleValues; int defaultSampleDescriptionIndex = ((atomFlags & 0x02 /* default_sample_description_index_present */) != 0) - ? tfhd.readUnsignedIntToInt() - 1 : defaultSampleValues.sampleDescriptionIndex; + ? tfhd.readUnsignedIntToInt() - 1 : defaultSampleValues.sampleDescriptionIndex; int defaultSampleDuration = ((atomFlags & 0x08 /* default_sample_duration_present */) != 0) ? tfhd.readUnsignedIntToInt() : defaultSampleValues.duration; int defaultSampleSize = ((atomFlags & 0x10 /* default_sample_size_present */) != 0) @@ -1081,7 +1097,7 @@ public final class FragmentedMp4Extractor implements Extractor { while (!pendingMetadataSampleInfos.isEmpty()) { MetadataSampleInfo sampleInfo = pendingMetadataSampleInfos.removeFirst(); pendingMetadataSampleBytes -= sampleInfo.size; - metadataTrackOutput.sampleMetadata( + eventMessageTrackOutput.sampleMetadata( sampleTimeUs + sampleInfo.presentationTimeDeltaUs, C.BUFFER_FLAG_KEY_FRAME, sampleInfo.size, pendingMetadataSampleBytes, null); } From 9617986538dc9ec637efcdaaf14af5369ce3b2c6 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 19 Jan 2017 13:11:44 -0800 Subject: [PATCH 85/92] Remove redundant MetadataDecoder.canDecode method This is no longer needed as MetadataDecoderFactory figures out which decoder should be used. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144999613 --- .../android/exoplayer2/metadata/MetadataDecoder.java | 8 -------- .../exoplayer2/metadata/emsg/EventMessageDecoder.java | 6 ------ .../android/exoplayer2/metadata/id3/Id3Decoder.java | 6 ------ .../exoplayer2/metadata/scte35/SpliceInfoDecoder.java | 7 ------- 4 files changed, 27 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java index 5c04bdaa2a..9137bad4fd 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java @@ -20,14 +20,6 @@ package com.google.android.exoplayer2.metadata; */ public interface MetadataDecoder { - /** - * Checks whether the decoder supports a given mime type. - * - * @param mimeType A metadata mime type. - * @return Whether the mime type is supported. - */ - boolean canDecode(String mimeType); - /** * Decodes a {@link Metadata} element from the provided input buffer. * diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java index b1cd5d2cf1..fd6996aa80 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.metadata.emsg; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import java.nio.ByteBuffer; import java.util.Arrays; @@ -31,11 +30,6 @@ import java.util.Arrays; */ public final class EventMessageDecoder implements MetadataDecoder { - @Override - public boolean canDecode(String mimeType) { - return MimeTypes.APPLICATION_EMSG.equals(mimeType); - } - @Override public Metadata decode(MetadataInputBuffer inputBuffer) { ByteBuffer buffer = inputBuffer.data; diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java index 9c3aa03271..16059ccfbf 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java @@ -20,7 +20,6 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.UnsupportedEncodingException; @@ -51,11 +50,6 @@ public final class Id3Decoder implements MetadataDecoder { private static final int ID3_TEXT_ENCODING_UTF_16BE = 2; private static final int ID3_TEXT_ENCODING_UTF_8 = 3; - @Override - public boolean canDecode(String mimeType) { - return mimeType.equals(MimeTypes.APPLICATION_ID3); - } - @Override public Metadata decode(MetadataInputBuffer inputBuffer) { ByteBuffer buffer = inputBuffer.data; diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java index dad8525d34..6e373a45e7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java @@ -15,12 +15,10 @@ */ package com.google.android.exoplayer2.metadata.scte35; -import android.text.TextUtils; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataDecoderException; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; import java.nio.ByteBuffer; @@ -44,11 +42,6 @@ public final class SpliceInfoDecoder implements MetadataDecoder { sectionHeader = new ParsableBitArray(); } - @Override - public boolean canDecode(String mimeType) { - return TextUtils.equals(mimeType, MimeTypes.APPLICATION_SCTE35); - } - @Override public Metadata decode(MetadataInputBuffer inputBuffer) throws MetadataDecoderException { ByteBuffer buffer = inputBuffer.data; From 7abc34c6ae29e1a3f349563a2a00bd30ea3e5fa4 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 19 Jan 2017 13:14:03 -0800 Subject: [PATCH 86/92] Respect decode-only flag in MetadataRenderer Issue #2176 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=144999973 --- .../google/android/exoplayer2/metadata/MetadataRenderer.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java index 4869611aeb..550a13771f 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java @@ -113,6 +113,10 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { if (result == C.RESULT_BUFFER_READ) { if (buffer.isEndOfStream()) { inputStreamEnded = true; + } else if (buffer.isDecodeOnly()) { + // Do nothing. Note this assumes that all metadata buffers can be decoded independently. + // If we ever need to support a metadata format where this is not the case, we'll need to + // pass the buffer to the decoder and discard the output. } else { pendingMetadataTimestamp = buffer.timeUs; buffer.subsampleOffsetUs = formatHolder.format.subsampleOffsetUs; From 6e18c97c209f6635813787f028a6335a04811671 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Jan 2017 05:55:41 -0800 Subject: [PATCH 87/92] Pull assertion and layer of indirection out from ChunkExtractorWrapper It should be possible to remove ChunkExtractorWrapper from the track output side as well (currently all extractor output is funneled via ChunkExtractorWrapper just so it can adjust the format, which is kind of unnecessary). ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=145076891 --- .../source/chunk/ChunkExtractorWrapper.java | 17 ++--------------- .../source/chunk/ContainerMediaChunk.java | 5 ++++- .../source/chunk/InitializationChunk.java | 5 ++++- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java index 9e3e5fb8c0..2623d31cef 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java @@ -47,7 +47,8 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } - private final Extractor extractor; + public final Extractor extractor; + private final Format manifestFormat; private final boolean preferManifestDrmInitData; private final boolean resendFormatOnInit; @@ -99,20 +100,6 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } } - /** - * Reads from the provided {@link ExtractorInput}. - * - * @param input The {@link ExtractorInput} from which to read. - * @return One of {@link Extractor#RESULT_CONTINUE} and {@link Extractor#RESULT_END_OF_INPUT}. - * @throws IOException If an error occurred reading from the source. - * @throws InterruptedException If the thread was interrupted. - */ - public int read(ExtractorInput input) throws IOException, InterruptedException { - int result = extractor.read(input, null); - Assertions.checkState(result != Extractor.RESULT_SEEK); - return result; - } - // ExtractorOutput implementation. @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index 5f2b843510..060e6130cf 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -120,10 +121,12 @@ public class ContainerMediaChunk extends BaseMediaChunk implements SeekMapOutput } // Load and decode the sample data. try { + Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractorWrapper.read(input); + result = extractor.read(input, null); } + Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java index c7ac2d66a9..c8c3389830 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -142,10 +143,12 @@ public final class InitializationChunk extends Chunk implements SeekMapOutput, } // Load and decode the initialization data. try { + Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractorWrapper.read(input); + result = extractor.read(input, null); } + Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } From 5407c98526f6ce96c9725577cc56f8c827349717 Mon Sep 17 00:00:00 2001 From: tap-prod Date: Fri, 20 Jan 2017 06:10:32 -0800 Subject: [PATCH 88/92] Automated rollback *** Original change description *** Pull assertion and layer of indirection out from ChunkExtractorWrapper It should be possible to remove ChunkExtractorWrapper from the track output side as well (currently all extractor output is funneled via ChunkExtractorWrapper just so it can adjust the format, which is kind of unnecessary). *** ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=145078094 --- .../source/chunk/ChunkExtractorWrapper.java | 17 +++++++++++++++-- .../source/chunk/ContainerMediaChunk.java | 5 +---- .../source/chunk/InitializationChunk.java | 5 +---- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java index 2623d31cef..9e3e5fb8c0 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java @@ -47,8 +47,7 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } - public final Extractor extractor; - + private final Extractor extractor; private final Format manifestFormat; private final boolean preferManifestDrmInitData; private final boolean resendFormatOnInit; @@ -100,6 +99,20 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } } + /** + * Reads from the provided {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @return One of {@link Extractor#RESULT_CONTINUE} and {@link Extractor#RESULT_END_OF_INPUT}. + * @throws IOException If an error occurred reading from the source. + * @throws InterruptedException If the thread was interrupted. + */ + public int read(ExtractorInput input) throws IOException, InterruptedException { + int result = extractor.read(input, null); + Assertions.checkState(result != Extractor.RESULT_SEEK); + return result; + } + // ExtractorOutput implementation. @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index 060e6130cf..5f2b843510 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -24,7 +24,6 @@ import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -121,12 +120,10 @@ public class ContainerMediaChunk extends BaseMediaChunk implements SeekMapOutput } // Load and decode the sample data. try { - Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, null); + result = extractorWrapper.read(input); } - Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java index c8c3389830..c7ac2d66a9 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -25,7 +25,6 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -143,12 +142,10 @@ public final class InitializationChunk extends Chunk implements SeekMapOutput, } // Load and decode the initialization data. try { - Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, null); + result = extractorWrapper.read(input); } - Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } From 26b303a4496b89a96aeea7e758174b7c80aa0f9e Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Jan 2017 07:31:51 -0800 Subject: [PATCH 89/92] Pull assertion and layer of indirection out from ChunkExtractorWrapper It should be possible to remove ChunkExtractorWrapper from the track output side as well (currently all extractor output is funneled via ChunkExtractorWrapper just so it can adjust the format, which is kind of unnecessary). ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=145083620 --- .../source/chunk/ChunkExtractorWrapper.java | 17 ++--------------- .../source/chunk/ContainerMediaChunk.java | 5 ++++- .../source/chunk/InitializationChunk.java | 5 ++++- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java index 9e3e5fb8c0..2623d31cef 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java @@ -47,7 +47,8 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } - private final Extractor extractor; + public final Extractor extractor; + private final Format manifestFormat; private final boolean preferManifestDrmInitData; private final boolean resendFormatOnInit; @@ -99,20 +100,6 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } } - /** - * Reads from the provided {@link ExtractorInput}. - * - * @param input The {@link ExtractorInput} from which to read. - * @return One of {@link Extractor#RESULT_CONTINUE} and {@link Extractor#RESULT_END_OF_INPUT}. - * @throws IOException If an error occurred reading from the source. - * @throws InterruptedException If the thread was interrupted. - */ - public int read(ExtractorInput input) throws IOException, InterruptedException { - int result = extractor.read(input, null); - Assertions.checkState(result != Extractor.RESULT_SEEK); - return result; - } - // ExtractorOutput implementation. @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index 5f2b843510..060e6130cf 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -120,10 +121,12 @@ public class ContainerMediaChunk extends BaseMediaChunk implements SeekMapOutput } // Load and decode the sample data. try { + Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractorWrapper.read(input); + result = extractor.read(input, null); } + Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java index c7ac2d66a9..c8c3389830 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -142,10 +143,12 @@ public final class InitializationChunk extends Chunk implements SeekMapOutput, } // Load and decode the initialization data. try { + Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractorWrapper.read(input); + result = extractor.read(input, null); } + Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } From 63604493b41d232602eca3f658dacd4d6690ec9e Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Fri, 20 Jan 2017 08:40:37 -0800 Subject: [PATCH 90/92] Fix memory leak in HlsMediaChunk's Issue:#2319 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=145089668 --- .../exoplayer2/source/hls/HlsMediaChunk.java | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index f9dba14e0e..0c411854d5 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -79,8 +79,10 @@ import java.util.concurrent.atomic.AtomicInteger; private final boolean isEncrypted; private final boolean isMasterTimestampSource; private final TimestampAdjuster timestampAdjuster; - private final HlsMediaChunk previousChunk; private final String lastPathSegment; + private final Extractor previousExtractor; + private final boolean shouldSpliceIn; + private final boolean needNewExtractor; private final boolean isPackedAudio; private final Id3Decoder id3Decoder; @@ -123,7 +125,6 @@ import java.util.concurrent.atomic.AtomicInteger; this.isMasterTimestampSource = isMasterTimestampSource; this.timestampAdjuster = timestampAdjuster; this.discontinuitySequenceNumber = discontinuitySequenceNumber; - this.previousChunk = previousChunk; // Note: this.dataSource and dataSource may be different. this.isEncrypted = this.dataSource instanceof Aes128DataSource; lastPathSegment = dataSpec.uri.getLastPathSegment(); @@ -131,13 +132,19 @@ import java.util.concurrent.atomic.AtomicInteger; || lastPathSegment.endsWith(AC3_FILE_EXTENSION) || lastPathSegment.endsWith(EC3_FILE_EXTENSION) || lastPathSegment.endsWith(MP3_FILE_EXTENSION); - if (isPackedAudio) { - id3Decoder = previousChunk != null ? previousChunk.id3Decoder : new Id3Decoder(); - id3Data = previousChunk != null ? previousChunk.id3Data - : new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH); + if (previousChunk != null) { + id3Decoder = previousChunk.id3Decoder; + id3Data = previousChunk.id3Data; + previousExtractor = previousChunk.extractor; + shouldSpliceIn = previousChunk.hlsUrl != hlsUrl; + needNewExtractor = previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber + || shouldSpliceIn; } else { - id3Decoder = null; - id3Data = null; + id3Decoder = isPackedAudio ? new Id3Decoder() : null; + id3Data = isPackedAudio ? new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH) : null; + previousExtractor = null; + shouldSpliceIn = false; + needNewExtractor = true; } initDataSource = dataSource; uid = UID_SOURCE.getAndIncrement(); @@ -151,7 +158,7 @@ import java.util.concurrent.atomic.AtomicInteger; */ public void init(HlsSampleStreamWrapper output) { extractorOutput = output; - output.init(uid, previousChunk != null && previousChunk.hlsUrl != hlsUrl); + output.init(uid, shouldSpliceIn); } @Override @@ -191,8 +198,8 @@ import java.util.concurrent.atomic.AtomicInteger; // Internal loading methods. private void maybeLoadInitData() throws IOException, InterruptedException { - if ((previousChunk != null && previousChunk.extractor == extractor) || initLoadCompleted - || initDataSpec == null) { + if (previousExtractor == extractor || initLoadCompleted || initDataSpec == null) { + // According to spec, for packed audio, initDataSpec is expected to be null. return; } DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded); @@ -325,9 +332,6 @@ import java.util.concurrent.atomic.AtomicInteger; private Extractor buildExtractorByExtension() { // Set the extractor that will read the chunk. Extractor extractor; - boolean needNewExtractor = previousChunk == null - || previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber - || trackFormat != previousChunk.trackFormat; boolean usingNewExtractor = true; if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { @@ -335,7 +339,7 @@ import java.util.concurrent.atomic.AtomicInteger; } else if (!needNewExtractor) { // Only reuse TS and fMP4 extractors. usingNewExtractor = false; - extractor = previousChunk.extractor; + extractor = previousExtractor; } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION)) { extractor = new FragmentedMp4Extractor(0, timestampAdjuster); } else { From 52d47aa244f677bf32ba8c3b7c284f61e44b66db Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Jan 2017 11:17:37 -0800 Subject: [PATCH 91/92] Fix possible track selection NPE If no tracks are selected at the start of playback, TrackSelectorResult isEquivalent(null) returned true, meaning we were keeping the old result (i.e. null), which we then tried to de-reference. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=145105702 --- .../trackselection/TrackSelectorResult.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java b/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java index 390b77391c..5cdb157570 100644 --- a/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java +++ b/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java @@ -61,10 +61,14 @@ public final class TrackSelectorResult { /** * Returns whether this result is equivalent to {@code other} for all renderers. * - * @param other The other {@link TrackSelectorResult}. May be null. + * @param other The other {@link TrackSelectorResult}. May be null, in which case {@code false} + * will be returned in all cases. * @return Whether this result is equivalent to {@code other} for all renderers. */ public boolean isEquivalent(TrackSelectorResult other) { + if (other == null) { + return false; + } for (int i = 0; i < selections.length; i++) { if (!isEquivalent(other, i)) { return false; @@ -78,13 +82,14 @@ public final class TrackSelectorResult { * The results are equivalent if they have equal track selections and configurations for the * renderer. * - * @param other The other {@link TrackSelectorResult}. May be null. + * @param other The other {@link TrackSelectorResult}. May be null, in which case {@code false} + * will be returned in all cases. * @param index The renderer index to check for equivalence. * @return Whether this result is equivalent to {@code other} for all renderers. */ public boolean isEquivalent(TrackSelectorResult other, int index) { if (other == null) { - return selections.get(index) == null && rendererConfigurations[index] == null; + return false; } return Util.areEqual(selections.get(index), other.selections.get(index)) && Util.areEqual(rendererConfigurations[index], other.rendererConfigurations[index]); From 55ca323cee1906e499e65acfdbb32a027f1e1376 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Fri, 20 Jan 2017 20:50:02 +0000 Subject: [PATCH 92/92] Add upstream.crypto package (and friends). --- .../upstream/cache/CacheDataSourceTest.java | 4 +- .../upstream/cache/CacheDataSourceTest2.java | 181 ++++++++++++++++ .../cache/CachedRegionTrackerTest.java | 126 +++++++++++ .../crypto/AesFlushingCipherTest.java | 186 ++++++++++++++++ .../upstream/cache/CachedRegionTracker.java | 205 ++++++++++++++++++ .../upstream/crypto/AesCipherDataSink.java | 95 ++++++++ .../upstream/crypto/AesCipherDataSource.java | 73 +++++++ .../upstream/crypto/AesFlushingCipher.java | 120 ++++++++++ .../upstream/crypto/CryptoUtil.java | 44 ++++ 9 files changed, 1033 insertions(+), 1 deletion(-) create mode 100644 library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java create mode 100644 library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java create mode 100644 library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 18e39be93c..c9eaa33204 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -27,7 +27,9 @@ import java.io.File; import java.io.IOException; import java.util.Arrays; -/** Unit tests for {@link CacheDataSource}. */ +/** + * Unit tests for {@link CacheDataSource}. + */ public class CacheDataSourceTest extends InstrumentationTestCase { private static final byte[] TEST_DATA = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java new file mode 100644 index 0000000000..70a7d797c1 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java @@ -0,0 +1,181 @@ +/* + * 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.upstream.cache; + +import android.content.Context; +import android.net.Uri; +import android.test.AndroidTestCase; +import android.test.MoreAsserts; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSink; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import com.google.android.exoplayer2.upstream.crypto.AesCipherDataSink; +import com.google.android.exoplayer2.upstream.crypto.AesCipherDataSource; +import com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.Random; + +/** + * Additional tests for {@link CacheDataSource}. + */ +public class CacheDataSourceTest2 extends AndroidTestCase { + + private static final String EXO_CACHE_DIR = "exo"; + private static final int EXO_CACHE_MAX_FILESIZE = 128; + + private static final Uri URI = Uri.parse("http://test.com/content"); + private static final String KEY = "key"; + private static final byte[] DATA = TestUtil.buildTestData(8 * EXO_CACHE_MAX_FILESIZE + 1); + + // A DataSpec that covers the full file. + private static final DataSpec FULL = new DataSpec(URI, 0, DATA.length, KEY); + + private static final int OFFSET_ON_BOUNDARY = EXO_CACHE_MAX_FILESIZE; + // A DataSpec that starts at 0 and extends to a cache file boundary. + private static final DataSpec END_ON_BOUNDARY = new DataSpec(URI, 0, OFFSET_ON_BOUNDARY, KEY); + // A DataSpec that starts on the same boundary and extends to the end of the file. + private static final DataSpec START_ON_BOUNDARY = new DataSpec(URI, OFFSET_ON_BOUNDARY, + DATA.length - OFFSET_ON_BOUNDARY, KEY); + + private static final int OFFSET_OFF_BOUNDARY = EXO_CACHE_MAX_FILESIZE * 2 + 1; + // A DataSpec that starts at 0 and extends to just past a cache file boundary. + private static final DataSpec END_OFF_BOUNDARY = new DataSpec(URI, 0, OFFSET_OFF_BOUNDARY, KEY); + // A DataSpec that starts on the same boundary and extends to the end of the file. + private static final DataSpec START_OFF_BOUNDARY = new DataSpec(URI, OFFSET_OFF_BOUNDARY, + DATA.length - OFFSET_OFF_BOUNDARY, KEY); + + public void testWithoutEncryption() throws IOException { + testReads(false); + } + + public void testWithEncryption() throws IOException { + testReads(true); + } + + private void testReads(boolean useEncryption) throws IOException { + FakeDataSource upstreamSource = buildFakeUpstreamSource(); + CacheDataSource source = buildCacheDataSource(getContext(), upstreamSource, useEncryption); + // First read, should arrive from upstream. + testRead(END_ON_BOUNDARY, source); + assertSingleOpen(upstreamSource, 0, OFFSET_ON_BOUNDARY); + // Second read, should arrive from upstream. + testRead(START_OFF_BOUNDARY, source); + assertSingleOpen(upstreamSource, OFFSET_OFF_BOUNDARY, DATA.length); + // Second read, should arrive part from cache and part from upstream. + testRead(END_OFF_BOUNDARY, source); + assertSingleOpen(upstreamSource, OFFSET_ON_BOUNDARY, OFFSET_OFF_BOUNDARY); + // Third read, should arrive from cache. + testRead(FULL, source); + assertNoOpen(upstreamSource); + // Various reads, should all arrive from cache. + testRead(FULL, source); + assertNoOpen(upstreamSource); + testRead(START_ON_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(END_ON_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(START_OFF_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(END_OFF_BOUNDARY, source); + assertNoOpen(upstreamSource); + } + + private void testRead(DataSpec dataSpec, CacheDataSource source) throws IOException { + byte[] scratch = new byte[4096]; + Random random = new Random(0); + source.open(dataSpec); + int position = (int) dataSpec.absoluteStreamPosition; + int bytesRead = 0; + while (bytesRead != C.RESULT_END_OF_INPUT) { + int maxBytesToRead = random.nextInt(scratch.length) + 1; + bytesRead = source.read(scratch, 0, maxBytesToRead); + if (bytesRead != C.RESULT_END_OF_INPUT) { + MoreAsserts.assertEquals(Arrays.copyOfRange(DATA, position, position + bytesRead), + Arrays.copyOf(scratch, bytesRead)); + position += bytesRead; + } + } + source.close(); + } + + /** + * Asserts that a single {@link DataSource#open(DataSpec)} call has been made to the upstream + * source, with the specified start (inclusive) and end (exclusive) positions. + */ + private void assertSingleOpen(FakeDataSource upstreamSource, int start, int end) { + DataSpec[] openedDataSpecs = upstreamSource.getAndClearOpenedDataSpecs(); + assertEquals(1, openedDataSpecs.length); + assertEquals(start, openedDataSpecs[0].position); + assertEquals(start, openedDataSpecs[0].absoluteStreamPosition); + assertEquals(end - start, openedDataSpecs[0].length); + } + + /** + * Asserts that the upstream source was not opened. + */ + private void assertNoOpen(FakeDataSource upstreamSource) { + DataSpec[] openedDataSpecs = upstreamSource.getAndClearOpenedDataSpecs(); + assertEquals(0, openedDataSpecs.length); + } + + private static FakeDataSource buildFakeUpstreamSource() { + return new FakeDataSource.Builder().appendReadData(DATA).build(); + } + + private static CacheDataSource buildCacheDataSource(Context context, DataSource upstreamSource, + boolean useAesEncryption) throws CacheException { + File cacheDir = context.getExternalCacheDir(); + Cache cache = new SimpleCache(new File(cacheDir, EXO_CACHE_DIR), new NoOpCacheEvictor()); + emptyCache(cache); + + // Source and cipher + final String secretKey = "testKey:12345678"; + DataSource file = new FileDataSource(); + DataSource cacheReadDataSource = useAesEncryption + ? new AesCipherDataSource(Util.getUtf8Bytes(secretKey), file) : file; + + // Sink and cipher + CacheDataSink cacheSink = new CacheDataSink(cache, EXO_CACHE_MAX_FILESIZE); + byte[] scratch = new byte[3897]; + DataSink cacheWriteDataSink = useAesEncryption + ? new AesCipherDataSink(Util.getUtf8Bytes(secretKey), cacheSink, scratch) : cacheSink; + + return new CacheDataSource(cache, + upstreamSource, + cacheReadDataSource, + cacheWriteDataSink, + CacheDataSource.FLAG_BLOCK_ON_CACHE, + null); // eventListener + } + + private static void emptyCache(Cache cache) throws CacheException { + for (String key : cache.getKeys()) { + for (CacheSpan span : cache.getCachedSpans(key)) { + cache.removeSpan(span); + } + } + // Sanity check that the cache really is empty now. + assertTrue(cache.getKeys().isEmpty()); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java new file mode 100644 index 0000000000..799027f4b5 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java @@ -0,0 +1,126 @@ +/* + * 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.exoplayer2.upstream.cache; + +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.extractor.ChunkIndex; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.File; +import java.io.IOException; +import org.mockito.Mock; + +/** + * Tests for {@link CachedRegionTracker}. + */ +public final class CachedRegionTrackerTest extends InstrumentationTestCase { + + private static final String CACHE_KEY = "abc"; + private static final long MS_IN_US = 1000; + + // 5 chunks, each 20 bytes long and 100 ms long. + private static final ChunkIndex CHUNK_INDEX = new ChunkIndex( + new int[] {20, 20, 20, 20, 20}, + new long[] {100, 120, 140, 160, 180}, + new long[] {100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US}, + new long[] {0, 100 * MS_IN_US, 200 * MS_IN_US, 300 * MS_IN_US, 400 * MS_IN_US}); + + @Mock private Cache cache; + private CachedRegionTracker tracker; + + private CachedContentIndex index; + private File cacheDir; + + @Override + protected void setUp() throws Exception { + TestUtil.setUpMockito(this); + + tracker = new CachedRegionTracker(cache, CACHE_KEY, CHUNK_INDEX); + + cacheDir = TestUtil.createTempFolder(getInstrumentation().getContext()); + index = new CachedContentIndex(cacheDir); + } + + @Override + protected void tearDown() throws Exception { + TestUtil.recursiveDelete(cacheDir); + } + + public void testGetRegion_noSpansInCache() { + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(100)); + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(150)); + } + + public void testGetRegion_fullyCached() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 100)); + + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(101)); + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_partiallyCached() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 40)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_multipleSpanAddsJoinedCorrectly() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 20)); + tracker.onSpanAdded( + cache, + newCacheSpan(120, 20)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_fullyCachedThenPartiallyRemoved() throws Exception { + // Start with the full stream in cache. + tracker.onSpanAdded( + cache, + newCacheSpan(100, 100)); + + // Remove the middle bit. + tracker.onSpanRemoved( + cache, + newCacheSpan(140, 40)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(181)); + } + + public void testGetRegion_subchunkEstimation() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 10)); + + assertEquals(50, tracker.getRegionEndTimeMs(101)); + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(111)); + } + + private CacheSpan newCacheSpan(int position, int length) throws IOException { + return SimpleCacheSpanTest.createCacheSpan(index, cacheDir, CACHE_KEY, position, length, 0); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java new file mode 100644 index 0000000000..b4e7e6e7f6 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java @@ -0,0 +1,186 @@ +/* + * 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.exoplayer2.upstream.crypto; + +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.Util; +import java.util.Random; +import javax.crypto.Cipher; +import junit.framework.TestCase; + +/** + * Unit tests for {@link AesFlushingCipher}. + */ +public class AesFlushingCipherTest extends TestCase { + + private static final int DATA_LENGTH = 65536; + private static final byte[] KEY = Util.getUtf8Bytes("testKey:12345678"); + private static final long NONCE = 0; + private static final long START_OFFSET = 11; + private static final long RANDOM_SEED = 0x12345678; + + private AesFlushingCipher encryptCipher; + private AesFlushingCipher decryptCipher; + + @Override + protected void setUp() { + encryptCipher = new AesFlushingCipher(Cipher.ENCRYPT_MODE, KEY, NONCE, START_OFFSET); + decryptCipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, KEY, NONCE, START_OFFSET); + } + + @Override + protected void tearDown() { + encryptCipher = null; + decryptCipher = null; + } + + private long getMaxUnchangedBytesAllowedPostEncryption(long length) { + // Assuming that not more than 10% of the resultant bytes should be identical. + // The value of 10% is arbitrary, ciphers standards do not name a value. + return length / 10; + } + + // Count the number of bytes that do not match. + private int getDifferingByteCount(byte[] data1, byte[] data2, int startOffset) { + int count = 0; + for (int i = startOffset; i < data1.length; i++) { + if (data1[i] != data2[i]) { + count++; + } + } + return count; + } + + // Count the number of bytes that do not match. + private int getDifferingByteCount(byte[] data1, byte[] data2) { + return getDifferingByteCount(data1, data2, 0); + } + + // Test a single encrypt and decrypt call + public void testSingle() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + + encryptCipher.updateInPlace(data, 0, data.length); + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + decryptCipher.updateInPlace(data, 0, data.length); + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test several encrypt and decrypt calls, each aligned on a 16 byte block size + public void testAligned() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + int offset = 0; + while (offset < data.length) { + int bytes = (1 + random.nextInt(50)) * 16; + bytes = Math.min(bytes, data.length - offset); + assertEquals(0, bytes % 16); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + offset = 0; + while (offset < data.length) { + int bytes = (1 + random.nextInt(50)) * 16; + bytes = Math.min(bytes, data.length - offset); + assertEquals(0, bytes % 16); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test several encrypt and decrypt calls, not aligned on block boundary + public void testUnAligned() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + // Encrypt + int offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test decryption starting from the middle of an encrypted block + public void testMidJoin() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + // Encrypt + int offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + // Verify + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + // Setup decryption from random location + offset = random.nextInt(4096); + decryptCipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, KEY, NONCE, offset + START_OFFSET); + int remainingLength = data.length - offset; + int originalOffset = offset; + + // Decrypt + while (remainingLength > 0) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, remainingLength); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + remainingLength -= bytes; + } + + // Verify + int differingByteCount = getDifferingByteCount(reference, data, originalOffset); + assertEquals(0, differingByteCount); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java new file mode 100644 index 0000000000..0f08ca40f2 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java @@ -0,0 +1,205 @@ +/* + * 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.exoplayer2.upstream.cache; + +import android.util.Log; +import com.google.android.exoplayer2.extractor.ChunkIndex; +import java.util.Arrays; +import java.util.Iterator; +import java.util.NavigableSet; +import java.util.TreeSet; + +/** + * Utility class for efficiently tracking regions of data that are stored in a {@link Cache} + * for a given cache key. + */ +public final class CachedRegionTracker implements Cache.Listener { + + private static final String TAG = "CachedRegionTracker"; + + public static final int NOT_CACHED = -1; + public static final int CACHED_TO_END = -2; + + private final Cache cache; + private final String cacheKey; + private final ChunkIndex chunkIndex; + + private final TreeSet regions; + private final Region lookupRegion; + + public CachedRegionTracker(Cache cache, String cacheKey, ChunkIndex chunkIndex) { + this.cache = cache; + this.cacheKey = cacheKey; + this.chunkIndex = chunkIndex; + this.regions = new TreeSet<>(); + this.lookupRegion = new Region(0, 0); + + synchronized (this) { + NavigableSet cacheSpans = cache.addListener(cacheKey, this); + if (cacheSpans != null) { + // Merge the spans into regions. mergeSpan is more efficient when merging from high to low, + // which is why a descending iterator is used here. + Iterator spanIterator = cacheSpans.descendingIterator(); + while (spanIterator.hasNext()) { + CacheSpan span = spanIterator.next(); + mergeSpan(span); + } + } + } + } + + public void release() { + cache.removeListener(cacheKey, this); + } + + /** + * When provided with a byte offset, this method locates the cached region within which the + * offset falls, and returns the approximate end position in milliseconds of that region. If the + * byte offset does not fall within a cached region then {@link #NOT_CACHED} is returned. + * If the cached region extends to the end of the stream, {@link #CACHED_TO_END} is returned. + * + * @param byteOffset The byte offset in the underlying stream. + * @return The end position of the corresponding cache region, {@link #NOT_CACHED}, or + * {@link #CACHED_TO_END}. + */ + public synchronized int getRegionEndTimeMs(long byteOffset) { + lookupRegion.startOffset = byteOffset; + Region floorRegion = regions.floor(lookupRegion); + if (floorRegion == null || byteOffset > floorRegion.endOffset + || floorRegion.endOffsetIndex == -1) { + return NOT_CACHED; + } + int index = floorRegion.endOffsetIndex; + if (index == chunkIndex.length - 1 + && floorRegion.endOffset == (chunkIndex.offsets[index] + chunkIndex.sizes[index])) { + return CACHED_TO_END; + } + long segmentFractionUs = (chunkIndex.durationsUs[index] + * (floorRegion.endOffset - chunkIndex.offsets[index])) / chunkIndex.sizes[index]; + return (int) ((chunkIndex.timesUs[index] + segmentFractionUs) / 1000); + } + + @Override + public synchronized void onSpanAdded(Cache cache, CacheSpan span) { + mergeSpan(span); + } + + @Override + public synchronized void onSpanRemoved(Cache cache, CacheSpan span) { + Region removedRegion = new Region(span.position, span.position + span.length); + + // Look up a region this span falls into. + Region floorRegion = regions.floor(removedRegion); + if (floorRegion == null) { + Log.e(TAG, "Removed a span we were not aware of"); + return; + } + + // Remove it. + regions.remove(floorRegion); + + // Add new floor and ceiling regions, if necessary. + if (floorRegion.startOffset < removedRegion.startOffset) { + Region newFloorRegion = new Region(floorRegion.startOffset, removedRegion.startOffset); + + int index = Arrays.binarySearch(chunkIndex.offsets, newFloorRegion.endOffset); + newFloorRegion.endOffsetIndex = index < 0 ? -index - 2 : index; + regions.add(newFloorRegion); + } + + if (floorRegion.endOffset > removedRegion.endOffset) { + Region newCeilingRegion = new Region(removedRegion.endOffset + 1, floorRegion.endOffset); + newCeilingRegion.endOffsetIndex = floorRegion.endOffsetIndex; + regions.add(newCeilingRegion); + } + } + + @Override + public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) { + // Do nothing. + } + + private void mergeSpan(CacheSpan span) { + Region newRegion = new Region(span.position, span.position + span.length); + Region floorRegion = regions.floor(newRegion); + Region ceilingRegion = regions.ceiling(newRegion); + boolean floorConnects = regionsConnect(floorRegion, newRegion); + boolean ceilingConnects = regionsConnect(newRegion, ceilingRegion); + + if (ceilingConnects) { + if (floorConnects) { + // Extend floorRegion to cover both newRegion and ceilingRegion. + floorRegion.endOffset = ceilingRegion.endOffset; + floorRegion.endOffsetIndex = ceilingRegion.endOffsetIndex; + } else { + // Extend newRegion to cover ceilingRegion. Add it. + newRegion.endOffset = ceilingRegion.endOffset; + newRegion.endOffsetIndex = ceilingRegion.endOffsetIndex; + regions.add(newRegion); + } + regions.remove(ceilingRegion); + } else if (floorConnects) { + // Extend floorRegion to the right to cover newRegion. + floorRegion.endOffset = newRegion.endOffset; + int index = floorRegion.endOffsetIndex; + while (index < chunkIndex.length - 1 + && (chunkIndex.offsets[index + 1] <= floorRegion.endOffset)) { + index++; + } + floorRegion.endOffsetIndex = index; + } else { + // This is a new region. + int index = Arrays.binarySearch(chunkIndex.offsets, newRegion.endOffset); + newRegion.endOffsetIndex = index < 0 ? -index - 2 : index; + regions.add(newRegion); + } + } + + private boolean regionsConnect(Region lower, Region upper) { + return lower != null && upper != null && lower.endOffset == upper.startOffset; + } + + private static class Region implements Comparable { + + /** + * The first byte of the region (inclusive). + */ + public long startOffset; + /** + * End offset of the region (exclusive). + */ + public long endOffset; + /** + * The index in chunkIndex that contains the end offset. May be -1 if the end offset comes + * before the start of the first media chunk (i.e. if the end offset is within the stream + * header). + */ + public int endOffsetIndex; + + public Region(long position, long endOffset) { + this.startOffset = position; + this.endOffset = endOffset; + } + + @Override + public int compareTo(Region another) { + return startOffset < another.startOffset ? -1 + : startOffset == another.startOffset ? 0 : 1; + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java new file mode 100644 index 0000000000..ccf9a5b3f5 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java @@ -0,0 +1,95 @@ +/* + * 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.exoplayer2.upstream.crypto; + +import com.google.android.exoplayer2.upstream.DataSink; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import javax.crypto.Cipher; + +/** + * A wrapping {@link DataSink} that encrypts the data being consumed. + */ +public final class AesCipherDataSink implements DataSink { + + private final DataSink wrappedDataSink; + private final byte[] secretKey; + private final byte[] scratch; + + private AesFlushingCipher cipher; + + /** + * Create an instance whose {@code write} methods have the side effect of overwriting the input + * {@code data}. Use this constructor for maximum efficiency in the case that there is no + * requirement for the input data arrays to remain unchanged. + * + * @param secretKey The key data. + * @param wrappedDataSink The wrapped {@link DataSink}. + */ + public AesCipherDataSink(byte[] secretKey, DataSink wrappedDataSink) { + this(secretKey, wrappedDataSink, null); + } + + /** + * Create an instance whose {@code write} methods are free of side effects. Use this constructor + * when the input data arrays are required to remain unchanged. + * + * @param secretKey The key data. + * @param wrappedDataSink The wrapped {@link DataSink}. + * @param scratch Scratch space. Data is decrypted into this array before being written to the + * wrapped {@link DataSink}. It should be of appropriate size for the expected writes. If a + * write is larger than the size of this array the write will still succeed, but multiple + * cipher calls will be required to complete the operation. + */ + public AesCipherDataSink(byte[] secretKey, DataSink wrappedDataSink, byte[] scratch) { + this.wrappedDataSink = wrappedDataSink; + this.secretKey = secretKey; + this.scratch = scratch; + } + + @Override + public void open(DataSpec dataSpec) throws IOException { + wrappedDataSink.open(dataSpec); + long nonce = CryptoUtil.getFNV64Hash(dataSpec.key); + cipher = new AesFlushingCipher(Cipher.ENCRYPT_MODE, secretKey, nonce, + dataSpec.absoluteStreamPosition); + } + + @Override + public void write(byte[] data, int offset, int length) throws IOException { + if (scratch == null) { + // In-place mode. Writes over the input data. + cipher.updateInPlace(data, offset, length); + wrappedDataSink.write(data, offset, length); + } else { + // Use scratch space. The original data remains intact. + int bytesProcessed = 0; + while (bytesProcessed < length) { + int bytesToProcess = Math.min(length - bytesProcessed, scratch.length); + cipher.update(data, offset + bytesProcessed, bytesToProcess, scratch, 0); + wrappedDataSink.write(scratch, 0, bytesToProcess); + bytesProcessed += bytesToProcess; + } + } + } + + @Override + public void close() throws IOException { + cipher = null; + wrappedDataSink.close(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java new file mode 100644 index 0000000000..26ac3b38fa --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java @@ -0,0 +1,73 @@ +/* + * 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.exoplayer2.upstream.crypto; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import javax.crypto.Cipher; + +/** + * A {@link DataSource} that decrypts the data read from an upstream source. + */ +public final class AesCipherDataSource implements DataSource { + + private final DataSource upstream; + private final byte[] secretKey; + + private AesFlushingCipher cipher; + + public AesCipherDataSource(byte[] secretKey, DataSource upstream) { + this.upstream = upstream; + this.secretKey = secretKey; + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + long dataLength = upstream.open(dataSpec); + long nonce = CryptoUtil.getFNV64Hash(dataSpec.key); + cipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, secretKey, nonce, + dataSpec.absoluteStreamPosition); + return dataLength; + } + + @Override + public int read(byte[] data, int offset, int readLength) throws IOException { + if (readLength == 0) { + return 0; + } + int read = upstream.read(data, offset, readLength); + if (read == C.RESULT_END_OF_INPUT) { + return C.RESULT_END_OF_INPUT; + } + cipher.updateInPlace(data, offset, read); + return read; + } + + @Override + public void close() throws IOException { + cipher = null; + upstream.close(); + } + + @Override + public Uri getUri() { + return upstream.getUri(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java new file mode 100644 index 0000000000..e093eb3064 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java @@ -0,0 +1,120 @@ +/* + * 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.exoplayer2.upstream.crypto; + +import com.google.android.exoplayer2.util.Assertions; +import java.nio.ByteBuffer; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * A flushing variant of a AES/CTR/NoPadding {@link Cipher}. + * + * Unlike a regular {@link Cipher}, the update methods of this class are guaranteed to process all + * of the bytes input (and hence output the same number of bytes). + */ +public final class AesFlushingCipher { + + private final Cipher cipher; + private final int blockSize; + private final byte[] zerosBlock; + private final byte[] flushedBlock; + + private int pendingXorBytes; + + public AesFlushingCipher(int mode, byte[] secretKey, long nonce, long offset) { + try { + cipher = Cipher.getInstance("AES/CTR/NoPadding"); + blockSize = cipher.getBlockSize(); + zerosBlock = new byte[blockSize]; + flushedBlock = new byte[blockSize]; + long counter = offset / blockSize; + int startPadding = (int) (offset % blockSize); + cipher.init(mode, new SecretKeySpec(secretKey, cipher.getAlgorithm().split("/")[0]), + new IvParameterSpec(getInitializationVector(nonce, counter))); + if (startPadding != 0) { + updateInPlace(new byte[startPadding], 0, startPadding); + } + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | InvalidAlgorithmParameterException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + public void updateInPlace(byte[] data, int offset, int length) { + update(data, offset, length, data, offset); + } + + public void update(byte[] in, int inOffset, int length, byte[] out, int outOffset) { + // If we previously flushed the cipher by inputting zeros up to a block boundary, then we need + // to manually transform the data that actually ended the block. See the comment below for more + // details. + while (pendingXorBytes > 0) { + out[outOffset] = (byte) (in[inOffset] ^ flushedBlock[blockSize - pendingXorBytes]); + outOffset++; + inOffset++; + pendingXorBytes--; + length--; + if (length == 0) { + return; + } + } + + // Do the bulk of the update. + int written = nonFlushingUpdate(in, inOffset, length, out, outOffset); + if (length == written) { + return; + } + + // We need to finish the block to flush out the remaining bytes. We do so by inputting zeros, + // so that the corresponding bytes output by the cipher are those that would have been XORed + // against the real end-of-block data to transform it. We store these bytes so that we can + // perform the transformation manually in the case of a subsequent call to this method with + // the real data. + int bytesToFlush = length - written; + Assertions.checkState(bytesToFlush < blockSize); + outOffset += written; + pendingXorBytes = blockSize - bytesToFlush; + written = nonFlushingUpdate(zerosBlock, 0, pendingXorBytes, flushedBlock, 0); + Assertions.checkState(written == blockSize); + // The first part of xorBytes contains the flushed data, which we copy out. The remainder + // contains the bytes that will be needed for manual transformation in a subsequent call. + for (int i = 0; i < bytesToFlush; i++) { + out[outOffset++] = flushedBlock[i]; + } + } + + private int nonFlushingUpdate(byte[] in, int inOffset, int length, byte[] out, int outOffset) { + try { + return cipher.update(in, inOffset, length, out, outOffset); + } catch (ShortBufferException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + private byte[] getInitializationVector(long nonce, long counter) { + return ByteBuffer.allocate(16).putLong(nonce).putLong(counter).array(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java new file mode 100644 index 0000000000..ff8841fa9c --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java @@ -0,0 +1,44 @@ +/* + * 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.exoplayer2.upstream.crypto; + +/** + * Utility functions for the crypto package. + */ +/* package */ final class CryptoUtil { + + private CryptoUtil() {} + + /** + * Returns the hash value of the input as a long using the 64 bit FNV-1a hash function. The hash + * values produced by this function are less likely to collide than those produced by + * {@link #hashCode()}. + */ + public static long getFNV64Hash(String input) { + if (input == null) { + return 0; + } + + long hash = 0; + for (int i = 0; i < input.length(); i++) { + hash ^= input.charAt(i); + // This is equivalent to hash *= 0x100000001b3 (the FNV magic prime number). + hash += (hash << 1) + (hash << 4) + (hash << 5) + (hash << 7) + (hash << 8) + (hash << 40); + } + return hash; + } + +}