diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/AssetListParser.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/AssetListParser.java new file mode 100644 index 0000000000..2265cc7923 --- /dev/null +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/AssetListParser.java @@ -0,0 +1,208 @@ +/* + * 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.exoplayer.hls; + +import android.net.Uri; +import android.util.JsonReader; +import android.util.JsonToken; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.exoplayer.upstream.ParsingLoadable; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Objects; + +/** Parses a X-ASSET-LIST JSON object. */ +/* package */ final class AssetListParser + implements ParsingLoadable.Parser { + + /** Holds assets. */ + public static final class AssetList { + + private static final AssetList EMPTY = new AssetList(ImmutableList.of(), ImmutableList.of()); + + /** The list of assets. */ + public final ImmutableList assets; + + /** The list of string attributes of the asset list JSON object. */ + public final ImmutableList stringAttributes; + + /** Creates an instance. */ + public AssetList(ImmutableList assets, ImmutableList stringAttributes) { + this.assets = assets; + this.stringAttributes = stringAttributes; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AssetList)) { + return false; + } + AssetList assetList = (AssetList) o; + return Objects.equals(assets, assetList.assets) + && Objects.equals(stringAttributes, assetList.stringAttributes); + } + + @Override + public int hashCode() { + return Objects.hash(assets, stringAttributes); + } + } + + /** + * An asset with a URI and a duration. + * + *

See RFC 8216bis, appendix D.2, X-ASSET-LIST. + */ + public static final class Asset { + + /** A uri to an HLS source. */ + public final Uri uri; + + /** The duration, in microseconds. */ + public final long durationUs; + + /** Creates an instance. */ + public Asset(Uri uri, long durationUs) { + this.uri = uri; + this.durationUs = durationUs; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Asset)) { + return false; + } + Asset asset = (Asset) o; + return durationUs == asset.durationUs && Objects.equals(uri, asset.uri); + } + + @Override + public int hashCode() { + return Objects.hash(uri, durationUs); + } + } + + /** A string attribute with its name and value. */ + public static final class StringAttribute { + + /** The name of the attribute. */ + public final String name; + + /** The value of the attribute. */ + public final String value; + + /** Creates an instance. */ + public StringAttribute(String name, String value) { + this.name = name; + this.value = value; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StringAttribute)) { + return false; + } + StringAttribute that = (StringAttribute) o; + return Objects.equals(name, that.name) && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } + } + + /** The asset name of the assets array in a X-ASSET-LIST JSON object. */ + private static final String ASSET_LIST_JSON_NAME_ASSET_ARRAY = "ASSETS"; + + /** The asset URI name in a X-ASSET-LIST JSON object. */ + private static final String ASSET_LIST_JSON_NAME_URI = "URI"; + + /** The asset duration name in a X-ASSET-LIST JSON object. */ + private static final String ASSET_LIST_JSON_NAME_DURATION = "DURATION"; + + @Override + public AssetList parse(Uri uri, InputStream inputStream) throws IOException { + try (JsonReader reader = new JsonReader(new InputStreamReader(inputStream))) { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + return AssetList.EMPTY; + } + ImmutableList.Builder assets = new ImmutableList.Builder<>(); + ImmutableList.Builder stringAttributes = new ImmutableList.Builder<>(); + reader.beginObject(); + while (reader.hasNext()) { + JsonToken token = reader.peek(); + if (token.equals(JsonToken.NAME)) { + String name = reader.nextName(); + if (name.equals(ASSET_LIST_JSON_NAME_ASSET_ARRAY) + && reader.peek() == JsonToken.BEGIN_ARRAY) { + parseAssetArray(reader, assets); + } else if (reader.peek() == JsonToken.STRING) { + stringAttributes.add(new StringAttribute(name, reader.nextString())); + } else { + reader.skipValue(); + } + } + } + return new AssetList(assets.build(), stringAttributes.build()); + } + } + + private static void parseAssetArray(JsonReader reader, ImmutableList.Builder assets) + throws IOException { + reader.beginArray(); + while (reader.hasNext()) { + if (reader.peek() == JsonToken.BEGIN_OBJECT) { + parseAssetObject(reader, assets); + } + } + reader.endArray(); + } + + private static void parseAssetObject(JsonReader reader, ImmutableList.Builder assets) + throws IOException { + reader.beginObject(); + String uri = null; + long duration = C.TIME_UNSET; + String name; + while (reader.hasNext()) { + name = reader.nextName(); + if (name.equals(ASSET_LIST_JSON_NAME_URI) && reader.peek() == JsonToken.STRING) { + uri = reader.nextString(); + } else if (name.equals(ASSET_LIST_JSON_NAME_DURATION) && reader.peek() == JsonToken.NUMBER) { + duration = (long) (reader.nextDouble() * C.MICROS_PER_SECOND); + } else { + reader.skipValue(); + } + } + if (uri != null && duration != C.TIME_UNSET) { + assets.add(new Asset(Uri.parse(uri), duration)); + } + reader.endObject(); + } +} diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/AssetListParserTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/AssetListParserTest.java new file mode 100644 index 0000000000..8e191d89f8 --- /dev/null +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/AssetListParserTest.java @@ -0,0 +1,119 @@ +/* + * 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.exoplayer.hls; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.net.Uri; +import androidx.media3.common.C; +import androidx.media3.datasource.ByteArrayDataSource; +import androidx.media3.exoplayer.hls.AssetListParser.Asset; +import androidx.media3.exoplayer.upstream.ParsingLoadable; +import androidx.media3.test.utils.TestUtil; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.EOFException; +import java.io.IOException; +import java.nio.charset.Charset; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class AssetListParserTest { + + @Test + public void load() throws IOException { + byte[] assetListBytes = + ("{\"ASSETS\": [ " + + "{\"URI\": \"http://1\", \"DURATION\":1.23}," + + "{\"URI\": \"http://2\", \"DURATION\":2.34}" + + "] }") + .getBytes(Charset.defaultCharset()); + ParsingLoadable parsingLoadable = + new ParsingLoadable<>( + new ByteArrayDataSource(assetListBytes), + Uri.EMPTY, + C.DATA_TYPE_AD, + new AssetListParser()); + + parsingLoadable.load(); + + assertThat(parsingLoadable.getResult().assets) + .containsExactly( + new Asset(Uri.parse("http://1"), /* durationUs= */ 1_230_000L), + new Asset(Uri.parse("http://2"), /* durationUs= */ 2_340_000L)) + .inOrder(); + } + + @Test + public void load_fileWithDisturbingJsonJunk_parsesCorrectly() throws IOException { + byte[] assetListBytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), + "media/hls/interstitials/x_asset_list_mixed_elements.json"); + ParsingLoadable parsingLoadable = + new ParsingLoadable<>( + new ByteArrayDataSource(assetListBytes), + Uri.EMPTY, + C.DATA_TYPE_AD, + new AssetListParser()); + + parsingLoadable.load(); + + assertThat(parsingLoadable.getResult().assets) + .containsExactly( + new Asset(Uri.parse("http://1"), 12_123_000L), + new Asset(Uri.parse("http://2"), 22_123_000L), + new Asset(Uri.parse("http://3"), 32_122_999L), + new Asset(Uri.parse("http://4"), 42_123_000L)) + .inOrder(); + assertThat(parsingLoadable.getResult().stringAttributes) + .containsExactly( + new AssetListParser.StringAttribute("foo", "foo"), + new AssetListParser.StringAttribute("fooBar", "fooBar"), + new AssetListParser.StringAttribute("ASSETS", "stringValue")) + .inOrder(); + } + + @Test + public void load_withJsonArrayAsRoot_emptyResult() throws IOException { + byte[] assetListBytes = "[]".getBytes(Charset.defaultCharset()); + ParsingLoadable parsingLoadable = + new ParsingLoadable<>( + new ByteArrayDataSource(assetListBytes), + Uri.EMPTY, + C.DATA_TYPE_AD, + new AssetListParser()); + + parsingLoadable.load(); + + assertThat(parsingLoadable.getResult().assets).isEmpty(); + assertThat(parsingLoadable.getResult().stringAttributes).isEmpty(); + } + + @Test + public void load_emptyInputStream_throwsEOFException() throws IOException { + ParsingLoadable parsingLoadable = + new ParsingLoadable<>( + new ByteArrayDataSource(" ".getBytes(Charset.defaultCharset())), + Uri.EMPTY, + C.DATA_TYPE_AD, + new AssetListParser()); + + assertThrows(EOFException.class, parsingLoadable::load); + } +} diff --git a/libraries/test_data/src/test/assets/media/hls/interstitials/x_asset_list_mixed_elements.json b/libraries/test_data/src/test/assets/media/hls/interstitials/x_asset_list_mixed_elements.json new file mode 100644 index 0000000000..827122815a --- /dev/null +++ b/libraries/test_data/src/test/assets/media/hls/interstitials/x_asset_list_mixed_elements.json @@ -0,0 +1,112 @@ +{ + "bar": [ + {}, + { + "foo": [ + { + "URI": "http://1", + "DURATION": 12.123 + }, + { + "URI": "http://2", + "DURATION": 22.123 + }, + { + "URI": "http://3", + "DURATION": 32.123 + } + ] + } + ], + "foo": "foo", + "fooObject": { + "attr1": "", + "attr2": 2.0 + }, + "ASSETS": [ + { + "URI": "http://1", + "DURATION": 12.123, + "foo": 12, + "fooBar": "text", + "booleanFoo": true, + "fooObject": { + "id": 23 + }, + "fooEmpty": {}, + "fooArray": [ + { + "id": "1" + }, + { + "id": "2" + } + ] + }, + { + "URI": null, + "DURATION": 12.123 + }, + { + "URI": 1.2, + "DURATION": 12.123 + }, + {}, + { + "URI": true, + "DURATION": 12.123 + }, + { + "DURATION": 12.123 + }, + { + "URI": "http://2", + "DURATION": 22.123 + }, + { + "URI": "http://2", + "DURATION": null + }, + { + "URI": "http://2", + "DURATION": "12.0" + }, + { + "URI": "http://2", + "DURATION": "twelve" + }, + { + "URI": "http://2", + "DURATION": false + }, + { + "URI": "http://2" + }, + { + "foo": 12, + "fooBar": "text", + "fooObject": { + "id": 23 + }, + "fooEmpty": {}, + "fooArray": [ + { + "id": "1" + } + ], + "URI": "http://3", + "DURATION": 32.123 + } + ], + "fooBar": "fooBar", + "barFoo": 12.00, + "nullFoo": null, + "ASSETS": "stringValue", + "ASSETS": [ + { + "URI": "http://4", + "DURATION": 42.123 + } + ], + "booleanFoo": true +}