Id3Decoder.java

/*
 * 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 androidx.media3.extractor.metadata.id3;

import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Metadata;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.ParsableBitArray;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.extractor.metadata.MetadataInputBuffer;
import androidx.media3.extractor.metadata.SimpleMetadataDecoder;
import com.google.common.base.Ascii;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;

/** Decodes ID3 tags. */
@UnstableApi
public final class Id3Decoder extends SimpleMetadataDecoder {

  /** A predicate for determining whether individual frames should be decoded. */
  public interface FramePredicate {

    /**
     * Returns whether a frame with the specified parameters should be decoded.
     *
     * @param majorVersion The major version of the ID3 tag.
     * @param id0 The first byte of the frame ID.
     * @param id1 The second byte of the frame ID.
     * @param id2 The third byte of the frame ID.
     * @param id3 The fourth byte of the frame ID.
     * @return Whether the frame should be decoded.
     */
    boolean evaluate(int majorVersion, int id0, int id1, int id2, int id3);
  }

  /** A predicate that indicates no frames should be decoded. */
  public static final FramePredicate NO_FRAMES_PREDICATE =
      (majorVersion, id0, id1, id2, id3) -> false;

  private static final String TAG = "Id3Decoder";

  /** The first three bytes of a well formed ID3 tag header. */
  public static final int ID3_TAG = 0x00494433;
  /** Length of an ID3 tag header. */
  public static final int ID3_HEADER_LENGTH = 10;

  private static final int FRAME_FLAG_V3_IS_COMPRESSED = 0x0080;
  private static final int FRAME_FLAG_V3_IS_ENCRYPTED = 0x0040;
  private static final int FRAME_FLAG_V3_HAS_GROUP_IDENTIFIER = 0x0020;
  private static final int FRAME_FLAG_V4_IS_COMPRESSED = 0x0008;
  private static final int FRAME_FLAG_V4_IS_ENCRYPTED = 0x0004;
  private static final int FRAME_FLAG_V4_HAS_GROUP_IDENTIFIER = 0x0040;
  private static final int FRAME_FLAG_V4_IS_UNSYNCHRONIZED = 0x0002;
  private static final int FRAME_FLAG_V4_HAS_DATA_LENGTH = 0x0001;

  private static final int ID3_TEXT_ENCODING_ISO_8859_1 = 0;
  private static final int ID3_TEXT_ENCODING_UTF_16 = 1;
  private static final int ID3_TEXT_ENCODING_UTF_16BE = 2;
  private static final int ID3_TEXT_ENCODING_UTF_8 = 3;

  @Nullable private final FramePredicate framePredicate;

  public Id3Decoder() {
    this(null);
  }

  /**
   * @param framePredicate Determines which frames are decoded. May be null to decode all frames.
   */
  public Id3Decoder(@Nullable FramePredicate framePredicate) {
    this.framePredicate = framePredicate;
  }

  @Override
  @Nullable
  @SuppressWarnings("ByteBufferBackingArray") // Buffer validated by SimpleMetadataDecoder.decode
  protected Metadata decode(MetadataInputBuffer inputBuffer, ByteBuffer buffer) {
    return decode(buffer.array(), buffer.limit());
  }

  /**
   * Decodes ID3 tags.
   *
   * @param data The bytes to decode ID3 tags from.
   * @param size Amount of bytes in {@code data} to read.
   * @return A {@link Metadata} object containing the decoded ID3 tags, or null if the data could
   *     not be decoded.
   */
  @Nullable
  public Metadata decode(byte[] data, int size) {
    List<Id3Frame> id3Frames = new ArrayList<>();
    ParsableByteArray id3Data = new ParsableByteArray(data, size);

    @Nullable Id3Header id3Header = decodeHeader(id3Data);
    if (id3Header == null) {
      return null;
    }

    int startPosition = id3Data.getPosition();
    int frameHeaderSize = id3Header.majorVersion == 2 ? 6 : 10;
    int framesSize = id3Header.framesSize;
    if (id3Header.isUnsynchronized) {
      framesSize = removeUnsynchronization(id3Data, id3Header.framesSize);
    }
    id3Data.setLimit(startPosition + framesSize);

    boolean unsignedIntFrameSizeHack = false;
    if (!validateFrames(id3Data, id3Header.majorVersion, frameHeaderSize, false)) {
      if (id3Header.majorVersion == 4 && validateFrames(id3Data, 4, frameHeaderSize, true)) {
        unsignedIntFrameSizeHack = true;
      } else {
        Log.w(TAG, "Failed to validate ID3 tag with majorVersion=" + id3Header.majorVersion);
        return null;
      }
    }

    while (id3Data.bytesLeft() >= frameHeaderSize) {
      @Nullable
      Id3Frame frame =
          decodeFrame(
              id3Header.majorVersion,
              id3Data,
              unsignedIntFrameSizeHack,
              frameHeaderSize,
              framePredicate);
      if (frame != null) {
        id3Frames.add(frame);
      }
    }

    return new Metadata(id3Frames);
  }

  /**
   * @param data A {@link ParsableByteArray} from which the header should be read.
   * @return The parsed header, or null if the ID3 tag is unsupported.
   */
  @Nullable
  private static Id3Header decodeHeader(ParsableByteArray data) {
    if (data.bytesLeft() < ID3_HEADER_LENGTH) {
      Log.w(TAG, "Data too short to be an ID3 tag");
      return null;
    }

    int id = data.readUnsignedInt24();
    if (id != ID3_TAG) {
      Log.w(TAG, "Unexpected first three bytes of ID3 tag header: 0x" + String.format("%06X", id));
      return null;
    }

    int majorVersion = data.readUnsignedByte();
    data.skipBytes(1); // Skip minor version.
    int flags = data.readUnsignedByte();
    int framesSize = data.readSynchSafeInt();

    if (majorVersion == 2) {
      boolean isCompressed = (flags & 0x40) != 0;
      if (isCompressed) {
        Log.w(TAG, "Skipped ID3 tag with majorVersion=2 and undefined compression scheme");
        return null;
      }
    } else if (majorVersion == 3) {
      boolean hasExtendedHeader = (flags & 0x40) != 0;
      if (hasExtendedHeader) {
        int extendedHeaderSize = data.readInt(); // Size excluding size field.
        data.skipBytes(extendedHeaderSize);
        framesSize -= (extendedHeaderSize + 4);
      }
    } else if (majorVersion == 4) {
      boolean hasExtendedHeader = (flags & 0x40) != 0;
      if (hasExtendedHeader) {
        int extendedHeaderSize = data.readSynchSafeInt(); // Size including size field.
        data.skipBytes(extendedHeaderSize - 4);
        framesSize -= extendedHeaderSize;
      }
      boolean hasFooter = (flags & 0x10) != 0;
      if (hasFooter) {
        framesSize -= 10;
      }
    } else {
      Log.w(TAG, "Skipped ID3 tag with unsupported majorVersion=" + majorVersion);
      return null;
    }

    // isUnsynchronized is advisory only in version 4. Frame level flags are used instead.
    boolean isUnsynchronized = majorVersion < 4 && (flags & 0x80) != 0;
    return new Id3Header(majorVersion, isUnsynchronized, framesSize);
  }

  private static boolean validateFrames(
      ParsableByteArray id3Data,
      int majorVersion,
      int frameHeaderSize,
      boolean unsignedIntFrameSizeHack) {
    int startPosition = id3Data.getPosition();
    try {
      while (id3Data.bytesLeft() >= frameHeaderSize) {
        // Read the next frame header.
        int id;
        long frameSize;
        int flags;
        if (majorVersion >= 3) {
          id = id3Data.readInt();
          frameSize = id3Data.readUnsignedInt();
          flags = id3Data.readUnsignedShort();
        } else {
          id = id3Data.readUnsignedInt24();
          frameSize = id3Data.readUnsignedInt24();
          flags = 0;
        }
        // Validate the frame header and skip to the next one.
        if (id == 0 && frameSize == 0 && flags == 0) {
          // We've reached zero padding after the end of the final frame.
          return true;
        } else {
          if (majorVersion == 4 && !unsignedIntFrameSizeHack) {
            // Parse the data size as a synchsafe integer, as per the spec.
            if ((frameSize & 0x808080L) != 0) {
              return false;
            }
            frameSize =
                (frameSize & 0xFF)
                    | (((frameSize >> 8) & 0xFF) << 7)
                    | (((frameSize >> 16) & 0xFF) << 14)
                    | (((frameSize >> 24) & 0xFF) << 21);
          }
          boolean hasGroupIdentifier = false;
          boolean hasDataLength = false;
          if (majorVersion == 4) {
            hasGroupIdentifier = (flags & FRAME_FLAG_V4_HAS_GROUP_IDENTIFIER) != 0;
            hasDataLength = (flags & FRAME_FLAG_V4_HAS_DATA_LENGTH) != 0;
          } else if (majorVersion == 3) {
            hasGroupIdentifier = (flags & FRAME_FLAG_V3_HAS_GROUP_IDENTIFIER) != 0;
            // A V3 frame has data length if and only if it's compressed.
            hasDataLength = (flags & FRAME_FLAG_V3_IS_COMPRESSED) != 0;
          }
          int minimumFrameSize = 0;
          if (hasGroupIdentifier) {
            minimumFrameSize++;
          }
          if (hasDataLength) {
            minimumFrameSize += 4;
          }
          if (frameSize < minimumFrameSize) {
            return false;
          }
          if (id3Data.bytesLeft() < frameSize) {
            return false;
          }
          id3Data.skipBytes((int) frameSize); // flags
        }
      }
      return true;
    } finally {
      id3Data.setPosition(startPosition);
    }
  }

  @Nullable
  private static Id3Frame decodeFrame(
      int majorVersion,
      ParsableByteArray id3Data,
      boolean unsignedIntFrameSizeHack,
      int frameHeaderSize,
      @Nullable FramePredicate framePredicate) {
    int frameId0 = id3Data.readUnsignedByte();
    int frameId1 = id3Data.readUnsignedByte();
    int frameId2 = id3Data.readUnsignedByte();
    int frameId3 = majorVersion >= 3 ? id3Data.readUnsignedByte() : 0;

    int frameSize;
    if (majorVersion == 4) {
      frameSize = id3Data.readUnsignedIntToInt();
      if (!unsignedIntFrameSizeHack) {
        frameSize =
            (frameSize & 0xFF)
                | (((frameSize >> 8) & 0xFF) << 7)
                | (((frameSize >> 16) & 0xFF) << 14)
                | (((frameSize >> 24) & 0xFF) << 21);
      }
    } else if (majorVersion == 3) {
      frameSize = id3Data.readUnsignedIntToInt();
    } else /* id3Header.majorVersion == 2 */ {
      frameSize = id3Data.readUnsignedInt24();
    }

    int flags = majorVersion >= 3 ? id3Data.readUnsignedShort() : 0;
    if (frameId0 == 0
        && frameId1 == 0
        && frameId2 == 0
        && frameId3 == 0
        && frameSize == 0
        && flags == 0) {
      // We must be reading zero padding at the end of the tag.
      id3Data.setPosition(id3Data.limit());
      return null;
    }

    int nextFramePosition = id3Data.getPosition() + frameSize;
    if (nextFramePosition > id3Data.limit()) {
      Log.w(TAG, "Frame size exceeds remaining tag data");
      id3Data.setPosition(id3Data.limit());
      return null;
    }

    if (framePredicate != null
        && !framePredicate.evaluate(majorVersion, frameId0, frameId1, frameId2, frameId3)) {
      // Filtered by the predicate.
      id3Data.setPosition(nextFramePosition);
      return null;
    }

    // Frame flags.
    boolean isCompressed = false;
    boolean isEncrypted = false;
    boolean isUnsynchronized = false;
    boolean hasDataLength = false;
    boolean hasGroupIdentifier = false;
    if (majorVersion == 3) {
      isCompressed = (flags & FRAME_FLAG_V3_IS_COMPRESSED) != 0;
      isEncrypted = (flags & FRAME_FLAG_V3_IS_ENCRYPTED) != 0;
      hasGroupIdentifier = (flags & FRAME_FLAG_V3_HAS_GROUP_IDENTIFIER) != 0;
      // A V3 frame has data length if and only if it's compressed.
      hasDataLength = isCompressed;
    } else if (majorVersion == 4) {
      hasGroupIdentifier = (flags & FRAME_FLAG_V4_HAS_GROUP_IDENTIFIER) != 0;
      isCompressed = (flags & FRAME_FLAG_V4_IS_COMPRESSED) != 0;
      isEncrypted = (flags & FRAME_FLAG_V4_IS_ENCRYPTED) != 0;
      isUnsynchronized = (flags & FRAME_FLAG_V4_IS_UNSYNCHRONIZED) != 0;
      hasDataLength = (flags & FRAME_FLAG_V4_HAS_DATA_LENGTH) != 0;
    }

    if (isCompressed || isEncrypted) {
      Log.w(TAG, "Skipping unsupported compressed or encrypted frame");
      id3Data.setPosition(nextFramePosition);
      return null;
    }

    if (hasGroupIdentifier) {
      frameSize--;
      id3Data.skipBytes(1);
    }
    if (hasDataLength) {
      frameSize -= 4;
      id3Data.skipBytes(4);
    }
    if (isUnsynchronized) {
      frameSize = removeUnsynchronization(id3Data, frameSize);
    }

    try {
      Id3Frame frame;
      if (frameId0 == 'T'
          && frameId1 == 'X'
          && frameId2 == 'X'
          && (majorVersion == 2 || frameId3 == 'X')) {
        frame = decodeTxxxFrame(id3Data, frameSize);
      } else if (frameId0 == 'T') {
        String id = getFrameId(majorVersion, frameId0, frameId1, frameId2, frameId3);
        frame = decodeTextInformationFrame(id3Data, frameSize, id);
      } else if (frameId0 == 'W'
          && frameId1 == 'X'
          && frameId2 == 'X'
          && (majorVersion == 2 || frameId3 == 'X')) {
        frame = decodeWxxxFrame(id3Data, frameSize);
      } else if (frameId0 == 'W') {
        String id = getFrameId(majorVersion, frameId0, frameId1, frameId2, frameId3);
        frame = decodeUrlLinkFrame(id3Data, frameSize, id);
      } else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') {
        frame = decodePrivFrame(id3Data, frameSize);
      } else if (frameId0 == 'G'
          && frameId1 == 'E'
          && frameId2 == 'O'
          && (frameId3 == 'B' || majorVersion == 2)) {
        frame = decodeGeobFrame(id3Data, frameSize);
      } else if (majorVersion == 2
          ? (frameId0 == 'P' && frameId1 == 'I' && frameId2 == 'C')
          : (frameId0 == 'A' && frameId1 == 'P' && frameId2 == 'I' && frameId3 == 'C')) {
        frame = decodeApicFrame(id3Data, frameSize, majorVersion);
      } else if (frameId0 == 'C'
          && frameId1 == 'O'
          && frameId2 == 'M'
          && (frameId3 == 'M' || majorVersion == 2)) {
        frame = decodeCommentFrame(id3Data, frameSize);
      } else if (frameId0 == 'C' && frameId1 == 'H' && frameId2 == 'A' && frameId3 == 'P') {
        frame =
            decodeChapterFrame(
                id3Data,
                frameSize,
                majorVersion,
                unsignedIntFrameSizeHack,
                frameHeaderSize,
                framePredicate);
      } else if (frameId0 == 'C' && frameId1 == 'T' && frameId2 == 'O' && frameId3 == 'C') {
        frame =
            decodeChapterTOCFrame(
                id3Data,
                frameSize,
                majorVersion,
                unsignedIntFrameSizeHack,
                frameHeaderSize,
                framePredicate);
      } else if (frameId0 == 'M' && frameId1 == 'L' && frameId2 == 'L' && frameId3 == 'T') {
        frame = decodeMlltFrame(id3Data, frameSize);
      } else {
        String id = getFrameId(majorVersion, frameId0, frameId1, frameId2, frameId3);
        frame = decodeBinaryFrame(id3Data, frameSize, id);
      }
      if (frame == null) {
        Log.w(
            TAG,
            "Failed to decode frame: id="
                + getFrameId(majorVersion, frameId0, frameId1, frameId2, frameId3)
                + ", frameSize="
                + frameSize);
      }
      return frame;
    } catch (UnsupportedEncodingException e) {
      Log.w(TAG, "Unsupported character encoding");
      return null;
    } finally {
      id3Data.setPosition(nextFramePosition);
    }
  }

  @Nullable
  private static TextInformationFrame decodeTxxxFrame(ParsableByteArray id3Data, int frameSize)
      throws UnsupportedEncodingException {
    if (frameSize < 1) {
      // Frame is malformed.
      return null;
    }

    int encoding = id3Data.readUnsignedByte();
    String charset = getCharsetName(encoding);

    byte[] data = new byte[frameSize - 1];
    id3Data.readBytes(data, 0, frameSize - 1);

    int descriptionEndIndex = indexOfEos(data, 0, encoding);
    String description = new String(data, 0, descriptionEndIndex, charset);

    int valueStartIndex = descriptionEndIndex + delimiterLength(encoding);
    int valueEndIndex = indexOfEos(data, valueStartIndex, encoding);
    String value = decodeStringIfValid(data, valueStartIndex, valueEndIndex, charset);

    return new TextInformationFrame("TXXX", description, value);
  }

  @Nullable
  private static TextInformationFrame decodeTextInformationFrame(
      ParsableByteArray id3Data, int frameSize, String id) throws UnsupportedEncodingException {
    if (frameSize < 1) {
      // Frame is malformed.
      return null;
    }

    int encoding = id3Data.readUnsignedByte();
    String charset = getCharsetName(encoding);

    byte[] data = new byte[frameSize - 1];
    id3Data.readBytes(data, 0, frameSize - 1);

    int valueEndIndex = indexOfEos(data, 0, encoding);
    String value = new String(data, 0, valueEndIndex, charset);

    return new TextInformationFrame(id, null, value);
  }

  @Nullable
  private static UrlLinkFrame decodeWxxxFrame(ParsableByteArray id3Data, int frameSize)
      throws UnsupportedEncodingException {
    if (frameSize < 1) {
      // Frame is malformed.
      return null;
    }

    int encoding = id3Data.readUnsignedByte();
    String charset = getCharsetName(encoding);

    byte[] data = new byte[frameSize - 1];
    id3Data.readBytes(data, 0, frameSize - 1);

    int descriptionEndIndex = indexOfEos(data, 0, encoding);
    String description = new String(data, 0, descriptionEndIndex, charset);

    int urlStartIndex = descriptionEndIndex + delimiterLength(encoding);
    int urlEndIndex = indexOfZeroByte(data, urlStartIndex);
    String url = decodeStringIfValid(data, urlStartIndex, urlEndIndex, "ISO-8859-1");

    return new UrlLinkFrame("WXXX", description, url);
  }

  private static UrlLinkFrame decodeUrlLinkFrame(
      ParsableByteArray id3Data, int frameSize, String id) throws UnsupportedEncodingException {
    byte[] data = new byte[frameSize];
    id3Data.readBytes(data, 0, frameSize);

    int urlEndIndex = indexOfZeroByte(data, 0);
    String url = new String(data, 0, urlEndIndex, "ISO-8859-1");

    return new UrlLinkFrame(id, null, url);
  }

  private static PrivFrame decodePrivFrame(ParsableByteArray id3Data, int frameSize)
      throws UnsupportedEncodingException {
    byte[] data = new byte[frameSize];
    id3Data.readBytes(data, 0, frameSize);

    int ownerEndIndex = indexOfZeroByte(data, 0);
    String owner = new String(data, 0, ownerEndIndex, "ISO-8859-1");

    int privateDataStartIndex = ownerEndIndex + 1;
    byte[] privateData = copyOfRangeIfValid(data, privateDataStartIndex, data.length);

    return new PrivFrame(owner, privateData);
  }

  private static GeobFrame decodeGeobFrame(ParsableByteArray id3Data, int frameSize)
      throws UnsupportedEncodingException {
    int encoding = id3Data.readUnsignedByte();
    String charset = getCharsetName(encoding);

    byte[] data = new byte[frameSize - 1];
    id3Data.readBytes(data, 0, frameSize - 1);

    int mimeTypeEndIndex = indexOfZeroByte(data, 0);
    String mimeType = new String(data, 0, mimeTypeEndIndex, "ISO-8859-1");

    int filenameStartIndex = mimeTypeEndIndex + 1;
    int filenameEndIndex = indexOfEos(data, filenameStartIndex, encoding);
    String filename = decodeStringIfValid(data, filenameStartIndex, filenameEndIndex, charset);

    int descriptionStartIndex = filenameEndIndex + delimiterLength(encoding);
    int descriptionEndIndex = indexOfEos(data, descriptionStartIndex, encoding);
    String description =
        decodeStringIfValid(data, descriptionStartIndex, descriptionEndIndex, charset);

    int objectDataStartIndex = descriptionEndIndex + delimiterLength(encoding);
    byte[] objectData = copyOfRangeIfValid(data, objectDataStartIndex, data.length);

    return new GeobFrame(mimeType, filename, description, objectData);
  }

  private static ApicFrame decodeApicFrame(
      ParsableByteArray id3Data, int frameSize, int majorVersion)
      throws UnsupportedEncodingException {
    int encoding = id3Data.readUnsignedByte();
    String charset = getCharsetName(encoding);

    byte[] data = new byte[frameSize - 1];
    id3Data.readBytes(data, 0, frameSize - 1);

    String mimeType;
    int mimeTypeEndIndex;
    if (majorVersion == 2) {
      mimeTypeEndIndex = 2;
      mimeType = "image/" + Ascii.toLowerCase(new String(data, 0, 3, "ISO-8859-1"));
      if ("image/jpg".equals(mimeType)) {
        mimeType = "image/jpeg";
      }
    } else {
      mimeTypeEndIndex = indexOfZeroByte(data, 0);
      mimeType = Ascii.toLowerCase(new String(data, 0, mimeTypeEndIndex, "ISO-8859-1"));
      if (mimeType.indexOf('/') == -1) {
        mimeType = "image/" + mimeType;
      }
    }

    int pictureType = data[mimeTypeEndIndex + 1] & 0xFF;

    int descriptionStartIndex = mimeTypeEndIndex + 2;
    int descriptionEndIndex = indexOfEos(data, descriptionStartIndex, encoding);
    String description =
        new String(
            data, descriptionStartIndex, descriptionEndIndex - descriptionStartIndex, charset);

    int pictureDataStartIndex = descriptionEndIndex + delimiterLength(encoding);
    byte[] pictureData = copyOfRangeIfValid(data, pictureDataStartIndex, data.length);

    return new ApicFrame(mimeType, description, pictureType, pictureData);
  }

  @Nullable
  private static CommentFrame decodeCommentFrame(ParsableByteArray id3Data, int frameSize)
      throws UnsupportedEncodingException {
    if (frameSize < 4) {
      // Frame is malformed.
      return null;
    }

    int encoding = id3Data.readUnsignedByte();
    String charset = getCharsetName(encoding);

    byte[] data = new byte[3];
    id3Data.readBytes(data, 0, 3);
    String language = new String(data, 0, 3);

    data = new byte[frameSize - 4];
    id3Data.readBytes(data, 0, frameSize - 4);

    int descriptionEndIndex = indexOfEos(data, 0, encoding);
    String description = new String(data, 0, descriptionEndIndex, charset);

    int textStartIndex = descriptionEndIndex + delimiterLength(encoding);
    int textEndIndex = indexOfEos(data, textStartIndex, encoding);
    String text = decodeStringIfValid(data, textStartIndex, textEndIndex, charset);

    return new CommentFrame(language, description, text);
  }

  private static ChapterFrame decodeChapterFrame(
      ParsableByteArray id3Data,
      int frameSize,
      int majorVersion,
      boolean unsignedIntFrameSizeHack,
      int frameHeaderSize,
      @Nullable FramePredicate framePredicate)
      throws UnsupportedEncodingException {
    int framePosition = id3Data.getPosition();
    int chapterIdEndIndex = indexOfZeroByte(id3Data.getData(), framePosition);
    String chapterId =
        new String(
            id3Data.getData(), framePosition, chapterIdEndIndex - framePosition, "ISO-8859-1");
    id3Data.setPosition(chapterIdEndIndex + 1);

    int startTime = id3Data.readInt();
    int endTime = 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<Id3Frame> subFrames = new ArrayList<>();
    int limit = framePosition + frameSize;
    while (id3Data.getPosition() < limit) {
      Id3Frame frame =
          decodeFrame(
              majorVersion, id3Data, unsignedIntFrameSizeHack, frameHeaderSize, framePredicate);
      if (frame != null) {
        subFrames.add(frame);
      }
    }

    Id3Frame[] subFrameArray = subFrames.toArray(new Id3Frame[0]);
    return new ChapterFrame(chapterId, startTime, endTime, startOffset, endOffset, subFrameArray);
  }

  private static ChapterTocFrame decodeChapterTOCFrame(
      ParsableByteArray id3Data,
      int frameSize,
      int majorVersion,
      boolean unsignedIntFrameSizeHack,
      int frameHeaderSize,
      @Nullable FramePredicate framePredicate)
      throws UnsupportedEncodingException {
    int framePosition = id3Data.getPosition();
    int elementIdEndIndex = indexOfZeroByte(id3Data.getData(), framePosition);
    String elementId =
        new String(
            id3Data.getData(), framePosition, elementIdEndIndex - framePosition, "ISO-8859-1");
    id3Data.setPosition(elementIdEndIndex + 1);

    int ctocFlags = id3Data.readUnsignedByte();
    boolean isRoot = (ctocFlags & 0x0002) != 0;
    boolean isOrdered = (ctocFlags & 0x0001) != 0;

    int childCount = id3Data.readUnsignedByte();
    String[] children = new String[childCount];
    for (int i = 0; i < childCount; i++) {
      int startIndex = id3Data.getPosition();
      int endIndex = indexOfZeroByte(id3Data.getData(), startIndex);
      children[i] = new String(id3Data.getData(), startIndex, endIndex - startIndex, "ISO-8859-1");
      id3Data.setPosition(endIndex + 1);
    }

    ArrayList<Id3Frame> subFrames = new ArrayList<>();
    int limit = framePosition + frameSize;
    while (id3Data.getPosition() < limit) {
      @Nullable
      Id3Frame frame =
          decodeFrame(
              majorVersion, id3Data, unsignedIntFrameSizeHack, frameHeaderSize, framePredicate);
      if (frame != null) {
        subFrames.add(frame);
      }
    }

    Id3Frame[] subFrameArray = subFrames.toArray(new Id3Frame[0]);
    return new ChapterTocFrame(elementId, isRoot, isOrdered, children, subFrameArray);
  }

  private static MlltFrame decodeMlltFrame(ParsableByteArray id3Data, int frameSize) {
    // See ID3v2.4.0 native frames subsection 4.6.
    int mpegFramesBetweenReference = id3Data.readUnsignedShort();
    int bytesBetweenReference = id3Data.readUnsignedInt24();
    int millisecondsBetweenReference = id3Data.readUnsignedInt24();
    int bitsForBytesDeviation = id3Data.readUnsignedByte();
    int bitsForMillisecondsDeviation = id3Data.readUnsignedByte();

    ParsableBitArray references = new ParsableBitArray();
    references.reset(id3Data);
    int referencesBits = 8 * (frameSize - 10);
    int bitsPerReference = bitsForBytesDeviation + bitsForMillisecondsDeviation;
    int referencesCount = referencesBits / bitsPerReference;
    int[] bytesDeviations = new int[referencesCount];
    int[] millisecondsDeviations = new int[referencesCount];
    for (int i = 0; i < referencesCount; i++) {
      int bytesDeviation = references.readBits(bitsForBytesDeviation);
      int millisecondsDeviation = references.readBits(bitsForMillisecondsDeviation);
      bytesDeviations[i] = bytesDeviation;
      millisecondsDeviations[i] = millisecondsDeviation;
    }

    return new MlltFrame(
        mpegFramesBetweenReference,
        bytesBetweenReference,
        millisecondsBetweenReference,
        bytesDeviations,
        millisecondsDeviations);
  }

  private static BinaryFrame decodeBinaryFrame(
      ParsableByteArray id3Data, int frameSize, String id) {
    byte[] frame = new byte[frameSize];
    id3Data.readBytes(frame, 0, frameSize);

    return new BinaryFrame(id, frame);
  }

  /**
   * Performs in-place removal of unsynchronization for {@code length} bytes starting from {@link
   * ParsableByteArray#getPosition()}
   *
   * @param data Contains the data to be processed.
   * @param length The length of the data to be processed.
   * @return The length of the data after processing.
   */
  private static int removeUnsynchronization(ParsableByteArray data, int length) {
    byte[] bytes = data.getData();
    int startPosition = data.getPosition();
    for (int i = startPosition; i + 1 < startPosition + length; i++) {
      if ((bytes[i] & 0xFF) == 0xFF && bytes[i + 1] == 0x00) {
        int relativePosition = i - startPosition;
        System.arraycopy(bytes, i + 2, bytes, i + 1, length - relativePosition - 2);
        length--;
      }
    }
    return length;
  }

  /**
   * Maps encoding byte from ID3v2 frame to a Charset.
   *
   * @param encodingByte The value of encoding byte from ID3v2 frame.
   * @return Charset name.
   */
  private static String getCharsetName(int encodingByte) {
    switch (encodingByte) {
      case ID3_TEXT_ENCODING_UTF_16:
        return "UTF-16";
      case ID3_TEXT_ENCODING_UTF_16BE:
        return "UTF-16BE";
      case ID3_TEXT_ENCODING_UTF_8:
        return "UTF-8";
      case ID3_TEXT_ENCODING_ISO_8859_1:
      default:
        return "ISO-8859-1";
    }
  }

  private static String getFrameId(
      int majorVersion, int frameId0, int frameId1, int frameId2, int frameId3) {
    return majorVersion == 2
        ? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2)
        : String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3);
  }

  private static int indexOfEos(byte[] data, int fromIndex, int encoding) {
    int terminationPos = indexOfZeroByte(data, fromIndex);

    // For single byte encoding charsets, we're done.
    if (encoding == ID3_TEXT_ENCODING_ISO_8859_1 || encoding == ID3_TEXT_ENCODING_UTF_8) {
      return terminationPos;
    }

    // Otherwise ensure an even offset from the start, and look for a second zero byte.
    while (terminationPos < data.length - 1) {
      if ((terminationPos - fromIndex) % 2 == 0 && data[terminationPos + 1] == (byte) 0) {
        return terminationPos;
      }
      terminationPos = indexOfZeroByte(data, terminationPos + 1);
    }

    return data.length;
  }

  private static int indexOfZeroByte(byte[] data, int fromIndex) {
    for (int i = fromIndex; i < data.length; i++) {
      if (data[i] == (byte) 0) {
        return i;
      }
    }
    return data.length;
  }

  private static int delimiterLength(int encodingByte) {
    return (encodingByte == ID3_TEXT_ENCODING_ISO_8859_1 || encodingByte == ID3_TEXT_ENCODING_UTF_8)
        ? 1
        : 2;
  }

  /**
   * Copies the specified range of an array, or returns a zero length array if the range is invalid.
   *
   * @param data The array from which to copy.
   * @param from The start of the range to copy (inclusive).
   * @param to The end of the range to copy (exclusive).
   * @return The copied data, or a zero length array if the range is invalid.
   */
  private static byte[] copyOfRangeIfValid(byte[] data, int from, int to) {
    if (to <= from) {
      // Invalid or zero length range.
      return Util.EMPTY_BYTE_ARRAY;
    }
    return Arrays.copyOfRange(data, from, to);
  }

  /**
   * Returns a string obtained by decoding the specified range of {@code data} using the specified
   * {@code charsetName}. An empty string is returned if the range is invalid.
   *
   * @param data The array from which to decode the string.
   * @param from The start of the range.
   * @param to The end of the range (exclusive).
   * @param charsetName The name of the Charset to use.
   * @return The decoded string, or an empty string if the range is invalid.
   * @throws UnsupportedEncodingException If the Charset is not supported.
   */
  private static String decodeStringIfValid(byte[] data, int from, int to, String charsetName)
      throws UnsupportedEncodingException {
    if (to <= from || to > data.length) {
      return "";
    }
    return new String(data, from, to - from, charsetName);
  }

  private static final class Id3Header {

    private final int majorVersion;
    private final boolean isUnsynchronized;
    private final int framesSize;

    public Id3Header(int majorVersion, boolean isUnsynchronized, int framesSize) {
      this.majorVersion = majorVersion;
      this.isUnsynchronized = isUnsynchronized;
      this.framesSize = framesSize;
    }
  }
}