DataSources: Enforce that opening at end-of-resource succeeds

- Update the three `HttpDataSource` implementations to use the
  Content-Range response header to determine when this is the
  case. The Content-Range header is included when the status
  code is 416. See [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416).
- Update `ByteArrayDataSource` to conform to the requirement.
- Update `DataSourceContractTest` to enforce the requirement.

PiperOrigin-RevId: 363642114
This commit is contained in:
olly 2021-03-18 13:14:59 +00:00 committed by Ian Baker
parent 8337991be3
commit 1affbf9357
8 changed files with 105 additions and 52 deletions

View file

@ -565,6 +565,16 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
int responseCode = responseInfo.getHttpStatusCode();
Map<String, List<String>> responseHeaders = responseInfo.getAllHeaders();
if (responseCode < 200 || responseCode > 299) {
if (responseCode == 416) {
long documentSize =
HttpUtil.getDocumentSize(getFirstHeader(responseHeaders, HttpHeaders.CONTENT_RANGE));
if (dataSpec.position == documentSize) {
opened = true;
transferStarted(dataSpec);
return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
}
}
byte[] responseBody;
try {
responseBody = readResponseBody();

View file

@ -28,6 +28,7 @@ import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.HttpUtil;
import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
@ -273,7 +274,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
@Override
public long open(DataSpec dataSpec) throws HttpDataSourceException {
this.dataSpec = dataSpec;
this.bytesRead = 0;
bytesRead = 0;
bytesToRead = 0;
transferInitializing(dataSpec);
Request request = makeRequest(dataSpec);
@ -298,6 +300,16 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
// Check for a valid response code.
if (!response.isSuccessful()) {
if (responseCode == 416) {
long documentSize =
HttpUtil.getDocumentSize(response.headers().get(HttpHeaders.CONTENT_RANGE));
if (dataSpec.position == documentSize) {
opened = true;
transferStarted(dataSpec);
return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
}
}
byte[] errorResponseBody;
try {
errorResponseBody = Util.toByteArray(Assertions.checkNotNull(responseByteStream));

View file

@ -330,7 +330,8 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
@Override
public long open(DataSpec dataSpec) throws HttpDataSourceException {
this.dataSpec = dataSpec;
this.bytesRead = 0;
bytesRead = 0;
bytesToRead = 0;
transferInitializing(dataSpec);
try {
@ -359,6 +360,16 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
// Check for a valid response code.
if (responseCode < 200 || responseCode > 299) {
Map<String, List<String>> headers = connection.getHeaderFields();
if (responseCode == 416) {
long documentSize =
HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE));
if (dataSpec.position == documentSize) {
opened = true;
transferStarted(dataSpec);
return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
}
}
@Nullable InputStream errorStream = connection.getErrorStream();
byte[] errorResponseBody;
try {
@ -371,7 +382,6 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
InvalidResponseCodeException exception =
new InvalidResponseCodeException(
responseCode, responseMessage, headers, dataSpec, errorResponseBody);
if (responseCode == 416) {
exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE));
}

View file

@ -32,6 +32,8 @@ public final class HttpUtil {
private static final String TAG = "HttpUtil";
private static final Pattern CONTENT_RANGE_WITH_START_AND_END =
Pattern.compile("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)");
private static final Pattern CONTENT_RANGE_WITH_SIZE =
Pattern.compile("bytes (?:(?:\\d+-\\d+)|\\*)/(\\d+)");
/** Class only contains static methods. */
private HttpUtil() {}
@ -59,6 +61,22 @@ public final class HttpUtil {
return rangeValue.toString();
}
/**
* Attempts to parse the document size from a {@link HttpHeaders#CONTENT_RANGE Content-Range
* header}.
*
* @param contentRangeHeader The {@link HttpHeaders#CONTENT_RANGE Content-Range header}, or {@code
* null} if not set.
* @return The document size, or {@link C#LENGTH_UNSET} if it could not be determined.
*/
public static long getDocumentSize(@Nullable String contentRangeHeader) {
if (TextUtils.isEmpty(contentRangeHeader)) {
return C.LENGTH_UNSET;
}
Matcher matcher = CONTENT_RANGE_WITH_SIZE.matcher(contentRangeHeader);
return matcher.matches() ? Long.parseLong(checkNotNull(matcher.group(1))) : C.LENGTH_UNSET;
}
/**
* Attempts to parse the length of a response body from the corresponding response headers.
*

View file

@ -17,6 +17,7 @@ package com.google.android.exoplayer2.upstream;
import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader;
import static com.google.android.exoplayer2.upstream.HttpUtil.getContentLength;
import static com.google.android.exoplayer2.upstream.HttpUtil.getDocumentSize;
import static com.google.common.truth.Truth.assertThat;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@ -78,4 +79,22 @@ public class HttpUtilTest {
assertThat(getContentLength(null, "unhandled 5-9/100")).isEqualTo(C.LENGTH_UNSET);
assertThat(getContentLength("10", "unhandled 0-4/100")).isEqualTo(10);
}
@Test
public void getDocumentSize_noHeader_returnsUnset() {
assertThat(getDocumentSize(null)).isEqualTo(C.LENGTH_UNSET);
assertThat(getDocumentSize("")).isEqualTo(C.LENGTH_UNSET);
}
@Test
public void getDocumentSize_returnsSize() {
assertThat(getDocumentSize("bytes */20")).isEqualTo(20);
assertThat(getDocumentSize("bytes 0-4/20")).isEqualTo(20);
}
@Test
public void getDocumentSize_ignoresUnhandledRangeUnits() {
assertThat(getDocumentSize("unhandled */20")).isEqualTo(C.LENGTH_UNSET);
assertThat(getDocumentSize("unhandled 0-4/20")).isEqualTo(C.LENGTH_UNSET);
}
}

View file

@ -47,16 +47,17 @@ public final class ByteArrayDataSource extends BaseDataSource {
public long open(DataSpec dataSpec) throws IOException {
uri = dataSpec.uri;
transferInitializing(dataSpec);
if (dataSpec.position >= data.length) {
if (dataSpec.position > data.length) {
throw new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE);
}
readPosition = (int) dataSpec.position;
bytesRemaining =
(int)
(dataSpec.length == C.LENGTH_UNSET ? data.length - dataSpec.position : dataSpec.length);
bytesRemaining = data.length - (int) dataSpec.position;
if (dataSpec.length != C.LENGTH_UNSET) {
bytesRemaining = (int) min(bytesRemaining, dataSpec.length);
}
opened = true;
transferStarted(dataSpec);
return bytesRemaining;
return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : bytesRemaining;
}
@Override

View file

@ -74,17 +74,17 @@ public final class ByteArrayDataSourceTest {
// And with bound.
readTestData(TEST_DATA, 1, 6, 1, 0, 1, false);
// Read from the last possible offset without bound.
readTestData(TEST_DATA, TEST_DATA.length - 1, C.LENGTH_UNSET, 1, 0, 1, false);
readTestData(TEST_DATA, TEST_DATA.length, C.LENGTH_UNSET, 1, 0, 1, false);
// And with bound.
readTestData(TEST_DATA, TEST_DATA.length - 1, 1, 1, 0, 1, false);
readTestData(TEST_DATA, TEST_DATA.length, 1, 1, 0, 1, false);
}
@Test
public void readFromInvalidOffsets() {
// Read from first invalid offset and check failure without bound.
readTestData(TEST_DATA, TEST_DATA.length, C.LENGTH_UNSET, 1, 0, 1, true);
readTestData(TEST_DATA, TEST_DATA.length + 1, C.LENGTH_UNSET, 1, 0, 1, true);
// And with bound.
readTestData(TEST_DATA, TEST_DATA.length, 1, 1, 0, 1, true);
readTestData(TEST_DATA, TEST_DATA.length + 1, 1, 1, 0, 1, true);
}
/**
@ -100,8 +100,10 @@ public final class ByteArrayDataSourceTest {
*/
private void readTestData(byte[] testData, int dataOffset, int dataLength, int outputBufferLength,
int writeOffset, int maxReadLength, boolean expectFailOnOpen) {
int expectedFinalBytesRead =
dataLength == C.LENGTH_UNSET ? (testData.length - dataOffset) : dataLength;
int expectedFinalBytesRead = testData.length - dataOffset;
if (dataLength != C.LENGTH_UNSET) {
expectedFinalBytesRead = min(expectedFinalBytesRead, dataLength);
}
ByteArrayDataSource dataSource = new ByteArrayDataSource(testData);
boolean opened = false;
try {
@ -111,7 +113,8 @@ public final class ByteArrayDataSourceTest {
assertThat(expectFailOnOpen).isFalse();
// Verify the resolved length is as we expect.
assertThat(length).isEqualTo(expectedFinalBytesRead);
assertThat(length)
.isEqualTo(dataLength != C.LENGTH_UNSET ? dataLength : expectedFinalBytesRead);
byte[] outputBuffer = new byte[outputBufferLength];
int accumulatedBytesRead = 0;

View file

@ -232,27 +232,17 @@ public abstract class DataSourceContractTest {
DataSpec dataSpec =
new DataSpec.Builder().setUri(resource.getUri()).setPosition(resourceLength).build();
try {
try {
long length = dataSource.open(dataSpec);
// The DataSource.open() contract requires the returned length to equal the length in the
// DataSpec if set. This is true even though the DataSource implementation may know that
// fewer bytes will be read in this case.
if (length != C.LENGTH_UNSET) {
assertThat(length).isEqualTo(0);
}
long length = dataSource.open(dataSpec);
byte[] data =
unboundedReadsAreIndefinite() ? Util.EMPTY_BYTE_ARRAY : Util.readToEnd(dataSource);
try {
byte[] data =
unboundedReadsAreIndefinite() ? Util.EMPTY_BYTE_ARRAY : Util.readToEnd(dataSource);
assertThat(data).isEmpty();
} catch (IOException e) {
// TODO: Remove this catch once the one below is removed.
throw new RuntimeException(e);
}
} catch (IOException e) {
// TODO: Remove this catch and require that implementations do not throw.
assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isTrue();
// The DataSource.open() contract requires the returned length to equal the length in the
// DataSpec if set. This is true even though the DataSource implementation may know that
// fewer bytes will be read in this case.
if (length != C.LENGTH_UNSET) {
assertThat(length).isEqualTo(0);
}
assertThat(data).isEmpty();
} finally {
dataSource.close();
}
@ -278,25 +268,15 @@ public abstract class DataSourceContractTest {
.setLength(1)
.build();
try {
try {
long length = dataSource.open(dataSpec);
// The DataSource.open() contract requires the returned length to equal the length in the
// DataSpec if set. This is true even though the DataSource implementation may know that
// fewer bytes will be read in this case.
assertThat(length).isEqualTo(1);
long length = dataSource.open(dataSpec);
byte[] data =
unboundedReadsAreIndefinite() ? Util.EMPTY_BYTE_ARRAY : Util.readToEnd(dataSource);
try {
byte[] data =
unboundedReadsAreIndefinite() ? Util.EMPTY_BYTE_ARRAY : Util.readToEnd(dataSource);
assertThat(data).isEmpty();
} catch (IOException e) {
// TODO: Remove this catch once the one below is removed.
throw new RuntimeException(e);
}
} catch (IOException e) {
// TODO: Remove this catch and require that implementations do not throw.
assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isTrue();
}
// The DataSource.open() contract requires the returned length to equal the length in the
// DataSpec if set. This is true even though the DataSource implementation may know that
// fewer bytes will be read in this case.
assertThat(length).isEqualTo(1);
assertThat(data).isEmpty();
} finally {
dataSource.close();
}