blob: 46bd482178f06958b3255070f90345db61373657 [file] [log] [blame]
/*
* 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.metadata.id3;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.MetadataInputBuffer;
import com.google.android.exoplayer2.metadata.SimpleMetadataDecoder;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.ParsableBitArray;
import com.google.android.exoplayer2.util.ParsableByteArray;
import com.google.android.exoplayer2.util.Util;
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. */
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;
}
}
}