diff --git a/libraries/common/src/main/java/androidx/media3/common/FileTypes.java b/libraries/common/src/main/java/androidx/media3/common/FileTypes.java index 38324d2f1ec..c88aa5470dc 100644 --- a/libraries/common/src/main/java/androidx/media3/common/FileTypes.java +++ b/libraries/common/src/main/java/androidx/media3/common/FileTypes.java @@ -59,6 +59,7 @@ public final class FileTypes { *
+ * MMTP signaling payload -> PA message (message_id 0x0000) -> MMT Package Table (id 0x20) + *+ * + *
The MMT Package Table (MPT) lists the assets that make up the package; for each asset it gives + * a four character {@code asset_type} (for example {@code hev1}) and one or more locations. Assets + * delivered on the same MMTP flow use location type {@code 0x00}, which carries the {@code + * packet_id} directly. + * + *
Observed NHK BS4K/BS8K packages, for reference: + * + *
These streams set {@code number_of_tables} to 0 in the PA message and inline the tables
+ * directly, so {@link #parsePaMessage} walks the concatenated tables rather than trusting the
+ * (empty) table index.
+ */
+/* package */ final class MmtSignalingParser {
+
+ /** A media asset discovered in the MMT Package Table. */
+ public static final class Asset {
+ /** The {@code packet_id} of the MMTP flow carrying the asset. */
+ public final int packetId;
+
+ /** The MMT {@code asset_type}, a four character code (see {@code ASSET_TYPE_*}). */
+ public final int assetType;
+
+ public Asset(int packetId, int assetType) {
+ this.packetId = packetId;
+ this.assetType = assetType;
+ }
+ }
+
+ /** {@code asset_type} four character codes. */
+ public static final int ASSET_TYPE_HEV1 = 0x68657631; // "hev1"
+
+ public static final int ASSET_TYPE_HVC1 = 0x68766331; // "hvc1"
+ public static final int ASSET_TYPE_AVC1 = 0x61766331; // "avc1"
+ public static final int ASSET_TYPE_AVC3 = 0x61766333; // "avc3"
+
+ private static final String TAG = "MmtSignalingParser";
+
+ private static final int MESSAGE_ID_PA = 0x0000;
+ private static final int TABLE_ID_MPT = 0x20;
+ private static final int LOCATION_TYPE_SAME_FLOW = 0x00;
+ private static final int LOCATION_TYPE_IPV4 = 0x01;
+ private static final int LOCATION_TYPE_IPV6 = 0x02;
+
+ // Signaling payload fragmentation indicator values.
+ private static final int FI_COMPLETE = 0;
+ private static final int FI_FIRST = 1;
+ private static final int FI_MIDDLE = 2;
+ private static final int FI_LAST = 3;
+
+ private final ParsableByteArray messageBuffer;
+
+ private ImmutableList Responsibilities:
+ *
+ * For timed HEVC/AVC assets each access unit is a set of length-prefixed NAL units. They are
+ * converted to Annex-B (start-code delimited) form so that the existing {@link H265Reader} /
+ * {@link H264Reader} elementary stream readers can extract the samples and derive the {@link
+ * androidx.media3.common.Format} from the in-band parameter sets.
+ */
+ private static final class MpuAssembler {
+
+ /** Default presentation duration per sample when timing metadata is unavailable (30 fps). */
+ private static final long DEFAULT_SAMPLE_DURATION_US = C.MICROS_PER_SECOND / 30;
+
+ private static final int NAL_LENGTH_FIELD_SIZE = 4;
+
+ private final ElementaryStreamReader reader;
+ private final boolean isVideo;
+ private final ParsableByteArray sampleData;
+
+ private long sampleDurationUs;
+ private long nextSampleTimeUs;
+ private boolean assembling;
+
+ public MpuAssembler(ElementaryStreamReader reader, int assetType) {
+ this.reader = reader;
+ this.isVideo =
+ assetType == MmtSignalingParser.ASSET_TYPE_HEV1
+ || assetType == MmtSignalingParser.ASSET_TYPE_HVC1
+ || assetType == MmtSignalingParser.ASSET_TYPE_AVC1
+ || assetType == MmtSignalingParser.ASSET_TYPE_AVC3;
+ sampleData = new ParsableByteArray(/* limit= */ 0);
+ sampleDurationUs = DEFAULT_SAMPLE_DURATION_US;
+ nextSampleTimeUs = 0;
+ }
+
+ public void reset() {
+ reader.seek();
+ sampleData.setPosition(0);
+ sampleData.setLimit(0);
+ assembling = false;
+ }
+
+ /** Parses ISO-BMFF metadata boxes to refine the per-sample duration when possible. */
+ public void consumeMetadata(int fragmentType, long mpuSequenceNumber, ParsableByteArray payload) {
+ // TODO: Parse moov (mdhd timescale) and moof (tfhd/trun default_sample_duration) to derive
+ // exact per-sample presentation times, and anchor them with the MPU_timestamp_descriptor from
+ // the MMT Package Table. Until then a fixed frame rate is assumed for video assets.
+ }
+
+ /**
+ * Consumes a single MFU data unit, reassembling fragmented access units.
+ *
+ * @param fragmentationIndicator One of the {@code FI_*} constants.
+ * @param timed Whether the MPU is a timed asset.
+ * @param mpuSequenceNumber The MPU sequence number (unused for now, see {@link
+ * #consumeMetadata}).
+ * @param mmtpTimestampNtp The MMTP transmission timestamp (NTP short format), used only as a
+ * coarse anchor for the first sample.
+ * @param payload The payload, positioned at the start of the data unit.
+ * @param limit The exclusive end position of this data unit within {@code payload}.
+ */
+ public void consumeTimedDataUnit(
+ int fragmentationIndicator,
+ boolean timed,
+ long mpuSequenceNumber,
+ long mmtpTimestampNtp,
+ ParsableByteArray payload,
+ int limit)
+ throws ParserException {
+ boolean hasDataUnitHeader =
+ fragmentationIndicator == FI_COMPLETE || fragmentationIndicator == FI_FIRST;
+ if (timed && hasDataUnitHeader) {
+ // Skip the 16-byte timed MFU data unit header: movie_fragment_sequence_number (4),
+ // sample_number (4), offset (4), priority (1), dependency_counter (1) + 2 reserved.
+ if (payload.getPosition() + 16 > limit) {
+ throw ParserException.createForMalformedContainer(
+ "Invalid timed MFU data unit", /* cause= */ null);
+ }
+ payload.skipBytes(16);
+ }
+ int length = limit - payload.getPosition();
+ if (length <= 0) {
+ return;
+ }
+ switch (fragmentationIndicator) {
+ case FI_COMPLETE:
+ startSample();
+ appendSampleData(payload, length);
+ emitSample(mmtpTimestampNtp);
+ break;
+ case FI_FIRST:
+ startSample();
+ appendSampleData(payload, length);
+ break;
+ case FI_MIDDLE:
+ if (assembling) {
+ appendSampleData(payload, length);
+ } else {
+ payload.skipBytes(length);
+ }
+ break;
+ case FI_LAST:
+ if (assembling) {
+ appendSampleData(payload, length);
+ emitSample(mmtpTimestampNtp);
+ } else {
+ payload.skipBytes(length);
+ }
+ break;
+ default:
+ payload.skipBytes(length);
+ break;
+ }
+ }
+
+ private void startSample() {
+ sampleData.setPosition(0);
+ sampleData.setLimit(0);
+ assembling = true;
+ }
+
+ private void appendSampleData(ParsableByteArray payload, int length) {
+ int currentLimit = sampleData.limit();
+ int requiredCapacity = currentLimit + length;
+ if (requiredCapacity > sampleData.capacity()) {
+ byte[] grown = new byte[max(sampleData.capacity() * 2, requiredCapacity)];
+ System.arraycopy(sampleData.getData(), 0, grown, 0, currentLimit);
+ sampleData.reset(grown, currentLimit);
+ }
+ System.arraycopy(
+ payload.getData(), payload.getPosition(), sampleData.getData(), currentLimit, length);
+ payload.skipBytes(length);
+ sampleData.setLimit(requiredCapacity);
+ }
+
+ private void emitSample(long mmtpTimestampNtp) {
+ if (!assembling) {
+ return;
+ }
+ assembling = false;
+ int sampleSize = sampleData.limit();
+ if (sampleSize <= 0) {
+ return;
+ }
+ if (isVideo) {
+ convertLengthPrefixedNalUnitsToAnnexB();
+ }
+ long timeUs = nextSampleTimeUs;
+ nextSampleTimeUs += sampleDurationUs;
+ sampleData.setPosition(0);
+ reader.packetStarted(timeUs, TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR);
+ try {
+ reader.consume(sampleData);
+ reader.packetFinished();
+ } catch (ParserException e) {
+ Log.w(TAG, "Discarding sample that could not be parsed", e);
+ }
+ }
+
+ /**
+ * Rewrites the reassembled access unit in place, replacing each 4-byte NAL unit length prefix
+ * with a 4-byte Annex-B start code ({@code 00 00 00 01}).
+ */
+ private void convertLengthPrefixedNalUnitsToAnnexB() {
+ byte[] data = sampleData.getData();
+ int limit = sampleData.limit();
+ int position = 0;
+ while (position + NAL_LENGTH_FIELD_SIZE <= limit) {
+ int nalLength =
+ ((data[position] & 0xFF) << 24)
+ | ((data[position + 1] & 0xFF) << 16)
+ | ((data[position + 2] & 0xFF) << 8)
+ | (data[position + 3] & 0xFF);
+ if (nalLength <= 0 || position + NAL_LENGTH_FIELD_SIZE + nalLength > limit) {
+ // Not length-prefixed (or corrupt); leave the remainder untouched.
+ break;
+ }
+ data[position] = 0x00;
+ data[position + 1] = 0x00;
+ data[position + 2] = 0x00;
+ data[position + 3] = 0x01;
+ position += NAL_LENGTH_FIELD_SIZE + nalLength;
+ }
+ }
+ }
+}
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/TlvExtractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/TlvExtractor.java
new file mode 100644
index 00000000000..c031776a455
--- /dev/null
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/TlvExtractor.java
@@ -0,0 +1,317 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.media3.extractor.mmt;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.lang.Math.max;
+
+import androidx.media3.common.C;
+import androidx.media3.common.util.ParsableByteArray;
+import androidx.media3.common.util.UnstableApi;
+import androidx.media3.extractor.Extractor;
+import androidx.media3.extractor.ExtractorInput;
+import androidx.media3.extractor.ExtractorOutput;
+import androidx.media3.extractor.ExtractorsFactory;
+import androidx.media3.extractor.PositionHolder;
+import androidx.media3.extractor.SeekMap;
+import java.io.EOFException;
+import java.io.IOException;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/**
+ * Extracts data from an MMT (MPEG Media Transport) elementary stream carried in TLV
+ * (Type-Length-Value) packets, as used by ISDB-S3 4K/8K broadcasting (ARIB STD-B32 / STD-B60).
+ *
+ * This extractor is responsible for the outermost transport layers only: it parses TLV packets,
+ * unwraps the IPv4/IPv6/header-compressed IP + UDP encapsulation, and passes the resulting MMTP
+ * packets to an {@link MmtpReader}. The {@link MmtpReader} performs MMTP header parsing, MPU/MFU
+ * reassembly and signaling handling to produce media samples.
+ *
+ * TLV packet syntax (ARIB STD-B32 Part 3):
+ *
+ * Only the "no compressed header" case (context type {@code 0x61}, where the payload follows a
+ * 2-byte context identification header) is currently unwrapped. Context establishing packets
+ * (types {@code 0x20}/{@code 0x21}) that carry a reconstructed IP header are skipped.
+ *
+ * TODO: Maintain per-CID header contexts and reconstruct compressed IP/UDP headers so that the
+ * transmitted timestamps and ports are available.
+ */
+ private void processCompressedIpPacket(ParsableByteArray packet) {
+ if (packet.bytesLeft() < 3) {
+ return;
+ }
+ // context_id (12 bits) + sequence_number (4 bits) + context_identification_header_type (8 bits).
+ packet.skipBytes(2); // CID + SN.
+ int contextHeaderType = packet.readUnsignedByte();
+ switch (contextHeaderType) {
+ case 0x61: // Compressed header for partial IPv6 header + partial UDP header.
+ case 0x60: // Compressed header for partial IPv4 header + partial UDP header.
+ // The MMTP payload immediately follows the context identification header.
+ dispatchMmtpPayload(packet);
+ break;
+ default:
+ // 0x20 / 0x21: full header transmission used to (re)establish a context. Skipped for now.
+ break;
+ }
+ }
+
+ private void dispatchUdpPayload(ParsableByteArray packet) {
+ if (packet.bytesLeft() < UDP_HEADER_SIZE) {
+ return;
+ }
+ packet.skipBytes(UDP_HEADER_SIZE); // Source/destination port, length, checksum.
+ dispatchMmtpPayload(packet);
+ }
+
+ private void dispatchMmtpPayload(ParsableByteArray packet) {
+ if (packet.bytesLeft() > 0) {
+ mmtpReader.consume(packet);
+ }
+ }
+
+ private static boolean isKnownPacketType(int packetType) {
+ return packetType == TLV_PACKET_TYPE_IPV4
+ || packetType == TLV_PACKET_TYPE_IPV6
+ || packetType == TLV_PACKET_TYPE_COMPRESSED_IP
+ || packetType == TLV_PACKET_TYPE_SIGNALLING
+ || packetType == TLV_PACKET_TYPE_NULL;
+ }
+
+ /** Provides an unseekable {@link SeekMap} for the (live) MMT/TLV stream. */
+ static SeekMap createUnseekableSeekMap() {
+ return new SeekMap.Unseekable(C.TIME_UNSET);
+ }
+}
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/package-info.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/package-info.java
new file mode 100644
index 00000000000..6725ceb1e13
--- /dev/null
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/package-info.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * Support for the MMT (MPEG Media Transport) over TLV (Type-Length-Value) transport stack used by
+ * ISDB-S3 based 4K/8K broadcasting (for example NHK BS4K/BS8K).
+ *
+ * The transport is layered as follows:
+ *
+ *
+ *
+ */
+/* package */ final class MmtpReader {
+
+ private static final String TAG = "MmtpReader";
+
+ /** MMTP payload types (ISO/IEC 23008-1 Table 8). */
+ private static final int PAYLOAD_TYPE_MPU = 0x00;
+
+ private static final int PAYLOAD_TYPE_SIGNALLING = 0x02;
+
+ /** MPU fragment types (ISO/IEC 23008-1 §9.4). */
+ private static final int MPU_FRAGMENT_TYPE_MPU_METADATA = 0x00;
+
+ private static final int MPU_FRAGMENT_TYPE_MOVIE_FRAGMENT_METADATA = 0x01;
+ private static final int MPU_FRAGMENT_TYPE_MFU = 0x02;
+
+ /** Fragmentation indicator values (ISO/IEC 23008-1 §9.4). */
+ private static final int FI_COMPLETE = 0;
+
+ private static final int FI_FIRST = 1;
+ private static final int FI_MIDDLE = 2;
+ private static final int FI_LAST = 3;
+
+ private final MmtSignalingParser signalingParser;
+ private final SparseArray
+ * TLV_packet() {
+ * sync_byte 8 bits // fixed 0x7F
+ * packet_type 8 bits // 0x01 IPv4, 0x02 IPv6, 0x03 header-compressed IP,
+ * // 0xFE transmission control signal, 0xFF null
+ * data_length 16 bits
+ * data_byte data_length bytes
+ * }
+ *
+ */
+@UnstableApi
+public final class TlvExtractor implements Extractor {
+
+ /** Factory for {@link TlvExtractor} instances. */
+ public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new TlvExtractor()};
+
+ /** Fixed value of the first byte of every TLV packet. */
+ public static final int TLV_SYNC_BYTE = 0x7F;
+
+ private static final int TLV_PACKET_TYPE_IPV4 = 0x01;
+ private static final int TLV_PACKET_TYPE_IPV6 = 0x02;
+ private static final int TLV_PACKET_TYPE_COMPRESSED_IP = 0x03;
+ private static final int TLV_PACKET_TYPE_SIGNALLING = 0xFE;
+ private static final int TLV_PACKET_TYPE_NULL = 0xFF;
+
+ private static final int TLV_HEADER_SIZE = 4;
+ private static final int IP_PROTOCOL_UDP = 17;
+ private static final int UDP_HEADER_SIZE = 8;
+ private static final int IPV6_HEADER_SIZE = 40;
+
+ /** Number of consecutive well-formed TLV packets required for a successful {@link #sniff}. */
+ private static final int SNIFF_TLV_PACKET_COUNT = 5;
+
+ private final ParsableByteArray tlvHeader;
+ private final ParsableByteArray tlvPayload;
+ private final MmtpReader mmtpReader;
+
+ private @MonotonicNonNull ExtractorOutput output;
+ private boolean tracksEnded;
+
+ public TlvExtractor() {
+ tlvHeader = new ParsableByteArray(TLV_HEADER_SIZE);
+ tlvPayload = new ParsableByteArray(/* limit= */ 0);
+ mmtpReader = new MmtpReader();
+ }
+
+ @Override
+ public boolean sniff(ExtractorInput input) throws IOException {
+ byte[] header = new byte[TLV_HEADER_SIZE];
+ int bytesPeeked = 0;
+ for (int i = 0; i < SNIFF_TLV_PACKET_COUNT; i++) {
+ if (!input.peekFully(header, /* offset= */ 0, TLV_HEADER_SIZE, /* allowEndOfInput= */ true)) {
+ return i > 0;
+ }
+ if ((header[0] & 0xFF) != TLV_SYNC_BYTE) {
+ return false;
+ }
+ int packetType = header[1] & 0xFF;
+ if (!isKnownPacketType(packetType)) {
+ return false;
+ }
+ int dataLength = ((header[2] & 0xFF) << 8) | (header[3] & 0xFF);
+ try {
+ input.advancePeekPosition(dataLength);
+ } catch (EOFException e) {
+ // The stream ended mid-packet; treat as a match if we already saw a well-formed packet.
+ return i > 0;
+ }
+ bytesPeeked += TLV_HEADER_SIZE + dataLength;
+ // Reset the peek buffer periodically so we don't require an unbounded peek window.
+ if (bytesPeeked > 512 * 1024) {
+ break;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void init(ExtractorOutput output) {
+ this.output = output;
+ mmtpReader.init(output);
+ }
+
+ @Override
+ public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException {
+ checkNotNull(output);
+ if (!readTlvHeader(input)) {
+ return RESULT_END_OF_INPUT;
+ }
+ int packetType = tlvHeader.getData()[1] & 0xFF;
+ tlvHeader.setPosition(2);
+ int dataLength = tlvHeader.readUnsignedShort();
+ if (dataLength > 0) {
+ prepareTlvPayload(input, dataLength);
+ processTlvPayload(packetType);
+ }
+ if (!tracksEnded) {
+ // Media tracks may be created lazily once signaling has been parsed. endTracks() is called by
+ // the reader when the package table has been resolved; before that we present no tracks.
+ tracksEnded = mmtpReader.maybeEndTracks();
+ }
+ return RESULT_CONTINUE;
+ }
+
+ @Override
+ public void seek(long position, long timeUs) {
+ // MMT/TLV streams are treated as live/unseekable for now (see seekMap in MmtpReader).
+ mmtpReader.seek();
+ }
+
+ @Override
+ public void release() {
+ // Do nothing.
+ }
+
+ /**
+ * Reads the 4-byte TLV packet header into {@link #tlvHeader}, resynchronising on the sync byte if
+ * necessary.
+ *
+ * @return Whether a header was read. False indicates the end of the stream.
+ */
+ private boolean readTlvHeader(ExtractorInput input) throws IOException {
+ // Resynchronise to the next sync byte to tolerate corrupted or partially delivered packets.
+ byte[] headerData = tlvHeader.getData();
+ if (!input.readFully(headerData, /* offset= */ 0, /* length= */ 1, /* allowEndOfInput= */ true)) {
+ return false;
+ }
+ int resyncAttempts = 0;
+ while ((headerData[0] & 0xFF) != TLV_SYNC_BYTE) {
+ if (++resyncAttempts > 188 * 8) {
+ // Give up resynchronising after a reasonable amount of data.
+ return false;
+ }
+ if (!input.readFully(headerData, /* offset= */ 0, /* length= */ 1, /* allowEndOfInput= */ true)) {
+ return false;
+ }
+ }
+ if (!input.readFully(
+ headerData, /* offset= */ 1, /* length= */ TLV_HEADER_SIZE - 1, /* allowEndOfInput= */ true)) {
+ return false;
+ }
+ tlvHeader.setPosition(0);
+ return true;
+ }
+
+ private void prepareTlvPayload(ExtractorInput input, int dataLength) throws IOException {
+ if (dataLength > tlvPayload.capacity()) {
+ tlvPayload.reset(new byte[max(tlvPayload.capacity() * 2, dataLength)], dataLength);
+ } else {
+ tlvPayload.setPosition(0);
+ tlvPayload.setLimit(dataLength);
+ }
+ input.readFully(tlvPayload.getData(), /* offset= */ 0, dataLength);
+ }
+
+ private void processTlvPayload(int packetType) {
+ switch (packetType) {
+ case TLV_PACKET_TYPE_IPV4:
+ processIpv4Packet(tlvPayload);
+ break;
+ case TLV_PACKET_TYPE_IPV6:
+ processIpv6Packet(tlvPayload);
+ break;
+ case TLV_PACKET_TYPE_COMPRESSED_IP:
+ processCompressedIpPacket(tlvPayload);
+ break;
+ case TLV_PACKET_TYPE_SIGNALLING:
+ // Transmission control signals (TLV-NIT / AMT). Not required for media playback.
+ break;
+ case TLV_PACKET_TYPE_NULL:
+ default:
+ // Null packets are stuffing and are ignored.
+ break;
+ }
+ }
+
+ /** Parses an uncompressed IPv4 datagram and dispatches the contained MMTP payload. */
+ private void processIpv4Packet(ParsableByteArray packet) {
+ if (packet.bytesLeft() < 20) {
+ return;
+ }
+ int startPosition = packet.getPosition();
+ int versionAndIhl = packet.readUnsignedByte();
+ int version = versionAndIhl >> 4;
+ int headerLength = (versionAndIhl & 0x0F) * 4;
+ if (version != 4 || headerLength < 20) {
+ return;
+ }
+ packet.skipBytes(8); // ToS, total length, identification, flags/fragment offset, TTL.
+ int protocol = packet.readUnsignedByte();
+ if (protocol != IP_PROTOCOL_UDP) {
+ return;
+ }
+ packet.setPosition(startPosition + headerLength);
+ dispatchUdpPayload(packet);
+ }
+
+ /** Parses an uncompressed IPv6 datagram and dispatches the contained MMTP payload. */
+ private void processIpv6Packet(ParsableByteArray packet) {
+ if (packet.bytesLeft() < IPV6_HEADER_SIZE) {
+ return;
+ }
+ int startPosition = packet.getPosition();
+ int versionAndClass = packet.readUnsignedByte();
+ if ((versionAndClass >> 4) != 6) {
+ return;
+ }
+ packet.skipBytes(5); // Remaining traffic class/flow label and payload length.
+ int nextHeader = packet.readUnsignedByte();
+ if (nextHeader != IP_PROTOCOL_UDP) {
+ // TODO: Follow IPv6 extension header chain before the UDP header when present.
+ return;
+ }
+ packet.setPosition(startPosition + IPV6_HEADER_SIZE);
+ dispatchUdpPayload(packet);
+ }
+
+ /**
+ * Parses an ARIB STD-B32 header-compressed IP packet.
+ *
+ *
+ * TLV packets
+ * -> IPv4 / IPv6 / header-compressed IP datagrams (UDP)
+ * -> MMTP packets
+ * -> MPU / MFU fragments -> HEVC / AVC access units
+ * -> MMT signaling messages -> PA message / MMT Package Table (asset discovery)
+ *
+ */
+@NonNullApi
+package androidx.media3.extractor.mmt;
+
+import androidx.media3.common.util.NonNullApi;
diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/MmtSignalingParserTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/MmtSignalingParserTest.java
new file mode 100644
index 00000000000..15db52452ac
--- /dev/null
+++ b/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/MmtSignalingParserTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.media3.extractor.mmt;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.media3.common.util.ParsableByteArray;
+import androidx.media3.test.utils.TestUtil;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link MmtSignalingParser}. */
+@RunWith(AndroidJUnit4.class)
+public class MmtSignalingParserTest {
+
+ /**
+ * A complete, non-aggregated signaling payload carrying a PA message whose MMT Package Table
+ * declares a single {@code hev1} asset delivered on {@code packet_id} 0x1001.
+ */
+ private static final byte[] PA_MESSAGE_WITH_SINGLE_HEVC_ASSET =
+ TestUtil.createByteArray(
+ // Signaling payload header: FI=complete, LEF=0, A=0; fragmentation_counter=0.
+ 0x00, 0x00,
+ // PA signaling_message: message_id=0x0000, version=0x00, length (32-bit, unused)=27.
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1B,
+ // PA body: number_of_tables=0 and the tables inlined directly (as ARIB STD-B60 streams
+ // such as NHK BS4K/BS8K actually transmit them).
+ 0x00,
+ // MMT Package Table (26 bytes):
+ 0x20, 0x00, // table_id, version.
+ 0x00, 0x16, // table_length = 22 (bytes following this field).
+ 0x00, // reserved(6) + MPT_mode(2).
+ 0x00, // MMT_package_id_length.
+ 0x00, 0x00, // MPT_descriptors_length.
+ 0x01, // number_of_assets.
+ // Asset:
+ 0x00, // identifier_type.
+ 0x00, 0x00, 0x00, 0x00, // asset_id_scheme.
+ 0x00, // asset_id_length.
+ 0x68, 0x65, 0x76, 0x31, // asset_type = "hev1".
+ 0x00, // reserved(7) + asset_clock_relation_flag(1).
+ 0x01, // location_count.
+ 0x00, 0x10, 0x01, // location_type=0x00 (same flow), packet_id=0x1001.
+ 0x00, 0x00 // asset_descriptors_length.
+ );
+
+ @Test
+ public void consume_paMessageWithHevcAsset_discoversAsset() {
+ MmtSignalingParser parser = new MmtSignalingParser();
+
+ boolean updated =
+ parser.consume(new ParsableByteArray(PA_MESSAGE_WITH_SINGLE_HEVC_ASSET));
+
+ assertThat(updated).isTrue();
+ ImmutableList