diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bd81e9a9a6..a081a97f2e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -45,6 +45,10 @@ * Downloads: * OkHttp Extension: * Cronet Extension: +* HttpEngine Extension: + * Implement `HttpEngineDataSource`, an `HttpDataSource` using the + [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine) + API. * RTMP Extension: * HLS Extension: * Refresh the HLS live playlist with an interval calculated from the last diff --git a/libraries/datasource_httpengine/README.md b/libraries/datasource_httpengine/README.md new file mode 100644 index 0000000000..359d092e46 --- /dev/null +++ b/libraries/datasource_httpengine/README.md @@ -0,0 +1,56 @@ +# HttpEngine DataSource module + +This module provides an [HttpDataSource][] implementation that uses +[HttpEngine][]. + +HttpEngine uses best HTTP stack available on the current platform. HttpEngine +was added in API level 34. + +[HttpDataSource]: ../datasource/src/main/java/androidx/media3/datasource/HttpDataSource.java +[HttpEngine]: https://developer.android.com/reference/android/net/http/HttpEngine + +## Getting the module + +The easiest way to get the module is to add it as a gradle dependency: + +```gradle +implementation 'androidx.media3:media3-datasource-httpengine:1.X.X' +``` + +where `1.X.X` is the version, which must match the version of the other media +modules being used. + +Alternatively, you can clone this GitHub project and depend on the module +locally. Instructions for doing this can be found in the [top level README][]. + +[top level README]: ../../README.md + +## Using the module + +Media components request data through `DataSource` instances. These instances +are obtained from instances of `DataSource.Factory`, which are instantiated and +injected from application code. + +If your application only needs to play http(s) content and the device is running +at least API level 34, using the HttpEngine extension is as simple as updating +`DataSource.Factory` instantiations in your application code to use +`HttpEngineDataSource.Factory`. If your application also needs to play +non-http(s) content such as local files, use: + +``` +new DefaultDataSource.Factory( + ... + /* baseDataSourceFactory= */ new HttpEngineDataSource.Factory(...) ); +``` + +## Cronet implementations + +To instantiate an `HttpEngineDataSource.Factory` you'll need an `HttpEngine`. A +`HttpEngine` can be obtained from the platform in API level 34 or greater. It's +recommended that an application should only have a single `HttpEngine` instance. + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/datasource/httpengine/package-summary diff --git a/libraries/datasource_httpengine/build.gradle b/libraries/datasource_httpengine/build.gradle new file mode 100644 index 0000000000..34b74830f4 --- /dev/null +++ b/libraries/datasource_httpengine/build.gradle @@ -0,0 +1,50 @@ +// Copyright (C) 2023 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. +apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle" + +android { + namespace 'androidx.media3.datasource.httpengine' + compileSdk 34 + + defaultConfig { + minSdk 34 + } + + publishing { + singleVariant('release') { + withSourcesJar() + } + } +} + +dependencies { + implementation project(modulePrefix + 'lib-common') + implementation project(modulePrefix + 'lib-datasource') + implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + compileOnly 'com.google.errorprone:error_prone_annotations:' + errorProneVersion + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion + androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion + androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion + androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:' + dexmakerVersion + androidTestImplementation project(modulePrefix + 'test-utils') + testImplementation project(modulePrefix + 'test-utils') + testImplementation 'org.robolectric:robolectric:' + robolectricVersion +} + +ext { + releaseArtifactId = 'media3-datasource-httpengine' + releaseName = 'Media3 HttpEngine DataSource module' +} +apply from: '../../publish.gradle' diff --git a/libraries/datasource_httpengine/src/androidTest/AndroidManifest.xml b/libraries/datasource_httpengine/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..75f64d9ed0 --- /dev/null +++ b/libraries/datasource_httpengine/src/androidTest/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + diff --git a/libraries/datasource_httpengine/src/androidTest/java/androidx/media3/datasource/httpengine/HttpEngineDataSourceContractTest.java b/libraries/datasource_httpengine/src/androidTest/java/androidx/media3/datasource/httpengine/HttpEngineDataSourceContractTest.java new file mode 100644 index 0000000000..622b980927 --- /dev/null +++ b/libraries/datasource_httpengine/src/androidTest/java/androidx/media3/datasource/httpengine/HttpEngineDataSourceContractTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023 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.datasource.httpengine; + +import android.net.Uri; +import android.net.http.HttpEngine; +import androidx.media3.datasource.DataSource; +import androidx.media3.test.utils.DataSourceContractTest; +import androidx.media3.test.utils.HttpDataSourceTestEnv; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.After; +import org.junit.Rule; +import org.junit.runner.RunWith; + +/** {@link DataSource} contract tests for {@link HttpEngineDataSource}. */ +@RunWith(AndroidJUnit4.class) +public class HttpEngineDataSourceContractTest extends DataSourceContractTest { + + @Rule public HttpDataSourceTestEnv httpDataSourceTestEnv = new HttpDataSourceTestEnv(); + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + + @After + public void tearDown() { + executorService.shutdown(); + } + + @Override + protected DataSource createDataSource() { + HttpEngine httpEngine = + new HttpEngine.Builder(ApplicationProvider.getApplicationContext()).build(); + return new HttpEngineDataSource.Factory(httpEngine, executorService).createDataSource(); + } + + @Override + protected ImmutableList getTestResources() { + return httpDataSourceTestEnv.getServedResources(); + } + + @Override + protected Uri getNotFoundUri() { + return Uri.parse(httpDataSourceTestEnv.getNonexistentUrl()); + } +} diff --git a/libraries/datasource_httpengine/src/main/AndroidManifest.xml b/libraries/datasource_httpengine/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..513957ba3e --- /dev/null +++ b/libraries/datasource_httpengine/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/ByteArrayUploadDataProvider.java b/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/ByteArrayUploadDataProvider.java new file mode 100644 index 0000000000..07f48ffecf --- /dev/null +++ b/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/ByteArrayUploadDataProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2023 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.datasource.httpengine; + +import static java.lang.Math.min; + +import android.net.http.UploadDataProvider; +import android.net.http.UploadDataSink; +import androidx.annotation.RequiresApi; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** A {@link UploadDataProvider} implementation that provides data from a {@code byte[]}. */ +@RequiresApi(34) +/* package */ final class ByteArrayUploadDataProvider extends UploadDataProvider { + + private final byte[] data; + + private int position; + + public ByteArrayUploadDataProvider(byte[] data) { + this.data = data; + } + + @Override + public long getLength() { + return data.length; + } + + @Override + public void read(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) throws IOException { + int readLength = min(byteBuffer.remaining(), data.length - position); + byteBuffer.put(data, position, readLength); + position += readLength; + uploadDataSink.onReadSucceeded(false); + } + + @Override + public void rewind(UploadDataSink uploadDataSink) throws IOException { + position = 0; + uploadDataSink.onRewindSucceeded(); + } +} diff --git a/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/HttpEngineDataSource.java b/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/HttpEngineDataSource.java new file mode 100644 index 0000000000..647a152ef9 --- /dev/null +++ b/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/HttpEngineDataSource.java @@ -0,0 +1,1155 @@ +/* + * Copyright (C) 2023 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.datasource.httpengine; + +import static android.net.http.UrlRequest.REQUEST_PRIORITY_MEDIUM; +import static androidx.media3.common.util.Util.castNonNull; +import static androidx.media3.datasource.HttpUtil.buildRangeRequestHeader; + +import android.net.Uri; +import android.net.http.HttpEngine; +import android.net.http.HttpException; +import android.net.http.NetworkException; +import android.net.http.UrlRequest; +import android.net.http.UrlRequest.Status; +import android.net.http.UrlResponseInfo; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.C; +import androidx.media3.common.MediaLibraryInfo; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.Clock; +import androidx.media3.common.util.ConditionVariable; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.BaseDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSourceException; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.HttpDataSource.CleartextNotPermittedException; +import androidx.media3.datasource.HttpDataSource.HttpDataSourceException; +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException; +import androidx.media3.datasource.HttpUtil; +import androidx.media3.datasource.TransferListener; +import com.google.common.base.Ascii; +import com.google.common.base.Predicate; +import com.google.common.net.HttpHeaders; +import com.google.common.primitives.Longs; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.Executor; + +/** + * DataSource without intermediate buffer based on {@link HttpEngine} set using {@link UrlRequest}. + * + *

Note: HTTP request headers will be set using all parameters passed via (in order of decreasing + * priority) the {@code dataSpec}, {@link #setRequestProperty} and the default parameters used to + * construct the instance. + */ +@RequiresApi(34) +@UnstableApi +public final class HttpEngineDataSource extends BaseDataSource implements HttpDataSource { + + static { + MediaLibraryInfo.registerModule("media3.datasource.httpengine"); + } + + /** {@link DataSource.Factory} for {@link HttpEngineDataSource} instances. */ + public static final class Factory implements HttpDataSource.Factory { + + private final HttpEngine httpEngine; + private final Executor executor; + private final RequestProperties defaultRequestProperties; + + @Nullable private Predicate contentTypePredicate; + @Nullable private TransferListener transferListener; + @Nullable private String userAgent; + private int requestPriority; + private int connectTimeoutMs; + private int readTimeoutMs; + private boolean resetTimeoutOnRedirects; + private boolean handleSetCookieRequests; + private boolean keepPostFor302Redirects; + + /** + * Creates an instance. + * + * @param httpEngine An {@link HttpEngine} to make the requests. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This + * may be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a + * thread hop from HttpEngine's internal network thread to the response handling thread. + * However, to avoid slowing down overall network performance, care must be taken to make + * sure response handling is a fast operation when using a direct executor. + */ + public Factory(HttpEngine httpEngine, Executor executor) { + this.httpEngine = Assertions.checkNotNull(httpEngine); + this.executor = executor; + defaultRequestProperties = new RequestProperties(); + requestPriority = REQUEST_PRIORITY_MEDIUM; + connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; + readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; + } + + @CanIgnoreReturnValue + @UnstableApi + @Override + public final Factory setDefaultRequestProperties(Map defaultRequestProperties) { + this.defaultRequestProperties.clearAndSet(defaultRequestProperties); + return this; + } + + /** + * Sets the user agent that will be used. + * + *

The default is {@code null}, which causes the default user agent of the underlying {@link + * HttpEngine} to be used. + * + * @param userAgent The user agent that will be used, or {@code null} to use the default user + * agent of the underlying {@link HttpEngine}. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + return this; + } + + /** + * Sets the priority of requests made by {@link HttpEngineDataSource} instances created by this + * factory. + * + *

The default is {@link UrlRequest#REQUEST_PRIORITY_MEDIUM}. + * + * @param requestPriority The request priority, which should be one of HttpEngine's {@code + * UrlRequest#REQUEST_PRIORITY_*} constants. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setRequestPriority(int requestPriority) { + this.requestPriority = requestPriority; + return this; + } + + /** + * Sets the connect timeout, in milliseconds. + * + *

The default is {@link HttpEngineDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. + * + * @param connectTimeoutMs The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setConnectionTimeoutMs(int connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + return this; + } + + /** + * Sets whether the connect timeout is reset when a redirect occurs. + * + *

The default is {@code false}. + * + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setResetTimeoutOnRedirects(boolean resetTimeoutOnRedirects) { + this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; + return this; + } + + /** + * Sets whether "Set-Cookie" requests on redirect should be forwarded to the redirect url in the + * "Cookie" header. + * + *

The default is {@code false}. + * + * @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded + * to the redirect url in the "Cookie" header. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setHandleSetCookieRequests(boolean handleSetCookieRequests) { + this.handleSetCookieRequests = handleSetCookieRequests; + return this; + } + + /** + * Sets the read timeout, in milliseconds. + * + *

The default is {@link HttpEngineDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. + * + * @param readTimeoutMs The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setReadTimeoutMs(int readTimeoutMs) { + this.readTimeoutMs = readTimeoutMs; + return this; + } + + /** + * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a + * {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link #open(DataSpec)}. + * + *

The default is {@code null}. + * + * @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a + * predicate that was previously set. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setContentTypePredicate(@Nullable Predicate contentTypePredicate) { + this.contentTypePredicate = contentTypePredicate; + return this; + } + + /** + * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for a + * POST request. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setKeepPostFor302Redirects(boolean keepPostFor302Redirects) { + this.keepPostFor302Redirects = keepPostFor302Redirects; + return this; + } + + /** + * Sets the {@link TransferListener} that will be used. + * + *

The default is {@code null}. + * + *

See {@link DataSource#addTransferListener(TransferListener)}. + * + * @param transferListener The listener that will be used. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setTransferListener(@Nullable TransferListener transferListener) { + this.transferListener = transferListener; + return this; + } + + @UnstableApi + @Override + public HttpDataSource createDataSource() { + HttpEngineDataSource dataSource = + new HttpEngineDataSource( + httpEngine, + executor, + requestPriority, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + handleSetCookieRequests, + userAgent, + defaultRequestProperties, + contentTypePredicate, + keepPostFor302Redirects); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + } + + /** Thrown when an error is encountered when trying to open a {@link HttpEngineDataSource}. */ + @UnstableApi + public static final class OpenException extends HttpDataSourceException { + + /** + * Returns the status of the connection establishment at the moment when the error occurred, as + * defined by {@link UrlRequest.Status}. + */ + public final int httpEngineConnectionStatus; + + public OpenException( + IOException cause, + DataSpec dataSpec, + @PlaybackException.ErrorCode int errorCode, + int httpEngineConnectionStatus) { + super(cause, dataSpec, errorCode, TYPE_OPEN); + this.httpEngineConnectionStatus = httpEngineConnectionStatus; + } + + public OpenException( + String errorMessage, + DataSpec dataSpec, + @PlaybackException.ErrorCode int errorCode, + int httpEngineConnectionStatus) { + super(errorMessage, dataSpec, errorCode, TYPE_OPEN); + this.httpEngineConnectionStatus = httpEngineConnectionStatus; + } + + public OpenException( + DataSpec dataSpec, + @PlaybackException.ErrorCode int errorCode, + int httpEngineConnectionStatus) { + super(dataSpec, errorCode, TYPE_OPEN); + this.httpEngineConnectionStatus = httpEngineConnectionStatus; + } + } + + /** The default connection timeout, in milliseconds. */ + @UnstableApi public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 8 * 1000; + + /** The default read timeout, in milliseconds. */ + @UnstableApi public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000; + + // The size of read buffer passed to cronet UrlRequest.read(). + private static final int READ_BUFFER_SIZE_BYTES = 32 * 1024; + + private final HttpEngine httpEngine; + private final Executor executor; + private final int requestPriority; + private final int connectTimeoutMs; + private final int readTimeoutMs; + private final boolean resetTimeoutOnRedirects; + private final boolean handleSetCookieRequests; + @Nullable private final String userAgent; + @Nullable private final RequestProperties defaultRequestProperties; + private final RequestProperties requestProperties; + private final ConditionVariable operation; + private final Clock clock; + + @Nullable private Predicate contentTypePredicate; + private final boolean keepPostFor302Redirects; + + // Accessed by the calling thread only. + private boolean opened; + private long bytesRemaining; + + @Nullable private DataSpec currentDataSpec; + @Nullable private UrlRequestWrapper currentUrlRequestWrapper; + + // Reference written and read by calling thread only. Passed to HttpEngine thread as a local + // variable. + // operation.open() calls ensure writes into the buffer are visible to reads made by the calling + // thread. + @Nullable private ByteBuffer readBuffer; + + // Written from the HttpEngine thread only. operation.open() calls ensure writes are visible to + // reads + // made by the calling thread. + @Nullable private UrlResponseInfo responseInfo; + @Nullable private IOException exception; + private boolean finished; + + private volatile long currentConnectTimeoutMs; + + @UnstableApi + /* package */ HttpEngineDataSource( + HttpEngine httpEngine, + Executor executor, + int requestPriority, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + boolean handleSetCookieRequests, + @Nullable String userAgent, + @Nullable RequestProperties defaultRequestProperties, + @Nullable Predicate contentTypePredicate, + boolean keepPostFor302Redirects) { + super(/* isNetwork= */ true); + this.httpEngine = Assertions.checkNotNull(httpEngine); + this.executor = Assertions.checkNotNull(executor); + this.requestPriority = requestPriority; + this.connectTimeoutMs = connectTimeoutMs; + this.readTimeoutMs = readTimeoutMs; + this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; + this.handleSetCookieRequests = handleSetCookieRequests; + this.userAgent = userAgent; + this.defaultRequestProperties = defaultRequestProperties; + this.contentTypePredicate = contentTypePredicate; + this.keepPostFor302Redirects = keepPostFor302Redirects; + clock = Clock.DEFAULT; + requestProperties = new RequestProperties(); + operation = new ConditionVariable(); + } + + // HttpDataSource implementation. + + @UnstableApi + @Override + public void setRequestProperty(String name, String value) { + requestProperties.set(name, value); + } + + @UnstableApi + @Override + public void clearRequestProperty(String name) { + requestProperties.remove(name); + } + + @UnstableApi + @Override + public void clearAllRequestProperties() { + requestProperties.clear(); + } + + @UnstableApi + @Override + public int getResponseCode() { + return responseInfo == null || responseInfo.getHttpStatusCode() <= 0 + ? -1 + : responseInfo.getHttpStatusCode(); + } + + @UnstableApi + @Override + public Map> getResponseHeaders() { + return responseInfo == null ? Collections.emptyMap() : responseInfo.getHeaders().getAsMap(); + } + + @UnstableApi + @Override + @Nullable + public Uri getUri() { + return responseInfo == null ? null : Uri.parse(responseInfo.getUrl()); + } + + @UnstableApi + @Override + public long open(DataSpec dataSpec) throws HttpDataSourceException { + Assertions.checkNotNull(dataSpec); + Assertions.checkState(!opened); + + operation.close(); + resetConnectTimeout(); + currentDataSpec = dataSpec; + UrlRequestWrapper urlRequestWrapper; + try { + urlRequestWrapper = buildRequestWrapper(dataSpec); + currentUrlRequestWrapper = urlRequestWrapper; + } catch (IOException e) { + if (e instanceof HttpDataSourceException) { + throw (HttpDataSourceException) e; + } else { + throw new OpenException( + e, dataSpec, PlaybackException.ERROR_CODE_IO_UNSPECIFIED, Status.IDLE); + } + } + urlRequestWrapper.start(); + + transferInitializing(dataSpec); + try { + boolean connectionOpened = blockUntilConnectTimeout(); + @Nullable IOException connectionOpenException = exception; + if (connectionOpenException != null) { + @Nullable String message = connectionOpenException.getMessage(); + if (message != null && Ascii.toLowerCase(message).contains("err_cleartext_not_permitted")) { + throw new CleartextNotPermittedException(connectionOpenException, dataSpec); + } + throw new OpenException( + connectionOpenException, + dataSpec, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + urlRequestWrapper.getStatus()); + } else if (!connectionOpened) { + // The timeout was reached before the connection was opened. + throw new OpenException( + new SocketTimeoutException(), + dataSpec, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + urlRequestWrapper.getStatus()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + // An interruption means the operation is being cancelled, in which case this exception should + // not cause the player to fail. If it does, it likely means that the owner of the operation + // is failing to swallow the interruption, which makes us enter an invalid state. + throw new OpenException( + new InterruptedIOException(), + dataSpec, + PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK, + Status.INVALID); + } + + // Check for a valid response code. + UrlResponseInfo responseInfo = Assertions.checkNotNull(this.responseInfo); + int responseCode = responseInfo.getHttpStatusCode(); + Map> responseHeaders = responseInfo.getHeaders().getAsMap(); + 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(); + } catch (IOException e) { + responseBody = Util.EMPTY_BYTE_ARRAY; + } + + @Nullable + IOException cause = + responseCode == 416 + ? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) + : null; + throw new InvalidResponseCodeException( + responseCode, + responseInfo.getHttpStatusText(), + cause, + responseHeaders, + dataSpec, + responseBody); + } + + // Check for a valid content type. + Predicate contentTypePredicate = this.contentTypePredicate; + if (contentTypePredicate != null) { + @Nullable String contentType = getFirstHeader(responseHeaders, HttpHeaders.CONTENT_TYPE); + if (contentType != null && !contentTypePredicate.apply(contentType)) { + throw new InvalidContentTypeException(contentType, dataSpec); + } + } + + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + long bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; + + // Calculate the content length. + if (!isCompressed(responseInfo)) { + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + long contentLength = + HttpUtil.getContentLength( + getFirstHeader(responseHeaders, HttpHeaders.CONTENT_LENGTH), + getFirstHeader(responseHeaders, HttpHeaders.CONTENT_RANGE)); + bytesRemaining = + contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET; + } + } else { + // If the response is compressed then the content length will be that of the compressed data + // which isn't what we want. Always use the dataSpec length in this case. + bytesRemaining = dataSpec.length; + } + + opened = true; + transferStarted(dataSpec); + + skipFully(bytesToSkip, dataSpec); + return bytesRemaining; + } + + @UnstableApi + @Override + public int read(byte[] buffer, int offset, int length) throws HttpDataSourceException { + Assertions.checkState(opened); + + if (length == 0) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + + ByteBuffer readBuffer = getOrCreateReadBuffer(); + if (!readBuffer.hasRemaining()) { + // Fill readBuffer with more data from HttpEngine. + operation.close(); + readBuffer.clear(); + + readInternal(readBuffer, castNonNull(currentDataSpec)); + + if (finished) { + bytesRemaining = 0; + return C.RESULT_END_OF_INPUT; + } + + // The operation didn't time out, fail or finish, and therefore data must have been read. + readBuffer.flip(); + Assertions.checkState(readBuffer.hasRemaining()); + } + + // Ensure we read up to bytesRemaining, in case this was a Range request with finite end, but + // the server does not support Range requests and transmitted the entire resource. + int bytesRead = + (int) + Longs.min( + bytesRemaining != C.LENGTH_UNSET ? bytesRemaining : Long.MAX_VALUE, + readBuffer.remaining(), + length); + + readBuffer.get(buffer, offset, bytesRead); + + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + bytesTransferred(bytesRead); + return bytesRead; + } + + /** + * Reads up to {@code buffer.remaining()} bytes of data and stores them into {@code buffer}, + * starting at {@code buffer.position()}. Advances the position of the buffer by the number of + * bytes read and returns this length. + * + *

If there is an error, a {@link HttpDataSourceException} is thrown and the contents of {@code + * buffer} should be ignored. If the exception has error code {@code + * HttpDataSourceException.TYPE_READ}, note that HttpEngine may continue writing into {@code + * buffer} after the method has returned. Thus the caller should not attempt to reuse the buffer. + * + *

If {@code buffer.remaining()} is zero then 0 is returned. Otherwise, if no data is available + * because the end of the opened range has been reached, then {@link C#RESULT_END_OF_INPUT} is + * returned. Otherwise, the call will block until at least one byte of data has been read and the + * number of bytes read is returned. + * + *

Passed buffer must be direct ByteBuffer. If you have a non-direct ByteBuffer, consider the + * alternative read method with its backed array. + * + * @param buffer The ByteBuffer into which the read data should be stored. Must be a direct + * ByteBuffer. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if no data is available + * because the end of the opened range has been reached. + * @throws HttpDataSourceException If an error occurs reading from the source. + * @throws IllegalArgumentException If {@code buffer} is not a direct ByteBuffer. + */ + @UnstableApi + public int read(ByteBuffer buffer) throws HttpDataSourceException { + Assertions.checkState(opened); + + if (!buffer.isDirect()) { + throw new IllegalArgumentException("Passed buffer is not a direct ByteBuffer"); + } + if (!buffer.hasRemaining()) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + int readLength = buffer.remaining(); + + if (readBuffer != null) { + // If there is existing data in the readBuffer, read as much as possible. Return if any read. + int copyBytes = copyByteBuffer(/* src= */ readBuffer, /* dst= */ buffer); + if (copyBytes != 0) { + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= copyBytes; + } + bytesTransferred(copyBytes); + return copyBytes; + } + } + + // Fill buffer with more data from HttpEngine. + operation.close(); + readInternal(buffer, castNonNull(currentDataSpec)); + + if (finished) { + bytesRemaining = 0; + return C.RESULT_END_OF_INPUT; + } + + // The operation didn't time out, fail or finish, and therefore data must have been read. + Assertions.checkState(readLength > buffer.remaining()); + int bytesRead = readLength - buffer.remaining(); + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + bytesTransferred(bytesRead); + return bytesRead; + } + + @UnstableApi + @Override + public synchronized void close() { + if (currentUrlRequestWrapper != null) { + currentUrlRequestWrapper.close(); + currentUrlRequestWrapper = null; + } + if (readBuffer != null) { + readBuffer.limit(0); + } + currentDataSpec = null; + responseInfo = null; + exception = null; + finished = false; + if (opened) { + opened = false; + transferEnded(); + } + } + + /** Returns current {@link UrlRequest.Callback}. May be null if the data source is not opened. */ + @UnstableApi + @VisibleForTesting + @Nullable + UrlRequest.Callback getCurrentUrlRequestCallback() { + return currentUrlRequestWrapper == null + ? null + : currentUrlRequestWrapper.getUrlRequestCallback(); + } + + private UrlRequestWrapper buildRequestWrapper(DataSpec dataSpec) throws IOException { + UrlRequestCallback callback = new UrlRequestCallback(); + return new UrlRequestWrapper(buildRequestBuilder(dataSpec, callback).build(), callback); + } + + private UrlRequest.Builder buildRequestBuilder( + DataSpec dataSpec, UrlRequest.Callback urlRequestCallback) throws IOException { + UrlRequest.Builder requestBuilder = + httpEngine + .newUrlRequestBuilder(dataSpec.uri.toString(), executor, urlRequestCallback) + .setPriority(requestPriority) + .setDirectExecutorAllowed(true); + + // Set the headers. + Map requestHeaders = new HashMap<>(); + if (defaultRequestProperties != null) { + requestHeaders.putAll(defaultRequestProperties.getSnapshot()); + } + requestHeaders.putAll(requestProperties.getSnapshot()); + requestHeaders.putAll(dataSpec.httpRequestHeaders); + + for (Entry headerEntry : requestHeaders.entrySet()) { + String key = headerEntry.getKey(); + String value = headerEntry.getValue(); + requestBuilder.addHeader(key, value); + } + + if (dataSpec.httpBody != null && !requestHeaders.containsKey(HttpHeaders.CONTENT_TYPE)) { + throw new OpenException( + "HTTP request with non-empty body must set Content-Type", + dataSpec, + PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK, + Status.IDLE); + } + + @Nullable String rangeHeader = buildRangeRequestHeader(dataSpec.position, dataSpec.length); + if (rangeHeader != null) { + requestBuilder.addHeader(HttpHeaders.RANGE, rangeHeader); + } + if (userAgent != null) { + requestBuilder.addHeader(HttpHeaders.USER_AGENT, userAgent); + } + // TODO: Uncomment when https://bugs.chromium.org/p/chromium/issues/detail?id=711810 is fixed + // (adjusting the code as necessary). + // Force identity encoding unless gzip is allowed. + // if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) { + // requestBuilder.addHeader("Accept-Encoding", "identity"); + // } + // Set the method and (if non-empty) the body. + requestBuilder.setHttpMethod(dataSpec.getHttpMethodString()); + if (dataSpec.httpBody != null) { + requestBuilder.setUploadDataProvider( + new ByteArrayUploadDataProvider(dataSpec.httpBody), executor); + } + return requestBuilder; + } + + // Internal methods. + + private boolean blockUntilConnectTimeout() throws InterruptedException { + long now = clock.elapsedRealtime(); + boolean opened = false; + while (!opened && now < currentConnectTimeoutMs) { + opened = operation.block(currentConnectTimeoutMs - now + 5 /* fudge factor */); + now = clock.elapsedRealtime(); + } + return opened; + } + + private void resetConnectTimeout() { + currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs; + } + + /** + * Attempts to skip the specified number of bytes in full. + * + *

The methods throws an {@link OpenException} with {@link OpenException#reason} set to {@link + * PlaybackException#ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE} when the data ended before the + * specified number of bytes were skipped. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpec The {@link DataSpec}. + * @throws HttpDataSourceException If the thread is interrupted during the operation, or an error + * occurs reading from the source; or when the data ended before the specified number of bytes + * were skipped. + */ + private void skipFully(long bytesToSkip, DataSpec dataSpec) throws HttpDataSourceException { + if (bytesToSkip == 0) { + return; + } + ByteBuffer readBuffer = getOrCreateReadBuffer(); + + try { + while (bytesToSkip > 0) { + // Fill readBuffer with more data from HttpEngine. + operation.close(); + readBuffer.clear(); + readInternal(readBuffer, dataSpec); + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedIOException(); + } + if (finished) { + throw new OpenException( + dataSpec, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + Status.READING_RESPONSE); + } else { + // The operation didn't time out, fail or finish, and therefore data must have been read. + readBuffer.flip(); + Assertions.checkState(readBuffer.hasRemaining()); + int bytesSkipped = (int) Math.min(readBuffer.remaining(), bytesToSkip); + readBuffer.position(readBuffer.position() + bytesSkipped); + bytesToSkip -= bytesSkipped; + } + } + } catch (IOException e) { + if (e instanceof HttpDataSourceException) { + throw (HttpDataSourceException) e; + } else { + throw new OpenException( + e, + dataSpec, + e instanceof SocketTimeoutException + ? PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT + : PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + Status.READING_RESPONSE); + } + } + } + + /** + * Reads the whole response body. + * + * @return The response body. + * @throws IOException If an error occurs reading from the source. + */ + private byte[] readResponseBody() throws IOException { + byte[] responseBody = Util.EMPTY_BYTE_ARRAY; + ByteBuffer readBuffer = getOrCreateReadBuffer(); + while (!finished) { + operation.close(); + readBuffer.clear(); + readInternal(readBuffer, castNonNull(currentDataSpec)); + readBuffer.flip(); + if (readBuffer.remaining() > 0) { + int existingResponseBodyEnd = responseBody.length; + responseBody = Arrays.copyOf(responseBody, responseBody.length + readBuffer.remaining()); + readBuffer.get(responseBody, existingResponseBodyEnd, readBuffer.remaining()); + } + } + return responseBody; + } + + /** + * Reads up to {@code buffer.remaining()} bytes of data from {@code currentUrlRequest} and stores + * them into {@code buffer}. If there is an error and {@code buffer == readBuffer}, then it resets + * the current {@code readBuffer} object so that it is not reused in the future. + * + * @param buffer The ByteBuffer into which the read data is stored. Must be a direct ByteBuffer. + * @throws HttpDataSourceException If an error occurs reading from the source. + */ + @SuppressWarnings("ReferenceEquality") + private void readInternal(ByteBuffer buffer, DataSpec dataSpec) throws HttpDataSourceException { + castNonNull(currentUrlRequestWrapper).read(buffer); + try { + if (!operation.block(readTimeoutMs)) { + throw new SocketTimeoutException(); + } + } catch (InterruptedException e) { + // The operation is ongoing so replace buffer to avoid it being written to by this + // operation during a subsequent request. + if (buffer == readBuffer) { + readBuffer = null; + } + Thread.currentThread().interrupt(); + exception = new InterruptedIOException(); + } catch (SocketTimeoutException e) { + // The operation is ongoing so replace buffer to avoid it being written to by this + // operation during a subsequent request. + if (buffer == readBuffer) { + readBuffer = null; + } + exception = + new HttpDataSourceException( + e, + dataSpec, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + HttpDataSourceException.TYPE_READ); + } + + if (exception != null) { + if (exception instanceof HttpDataSourceException) { + throw (HttpDataSourceException) exception; + } else { + throw HttpDataSourceException.createForIOException( + exception, dataSpec, HttpDataSourceException.TYPE_READ); + } + } + } + + private ByteBuffer getOrCreateReadBuffer() { + if (readBuffer == null) { + readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); + readBuffer.limit(0); + } + return readBuffer; + } + + private static boolean isCompressed(UrlResponseInfo info) { + for (Map.Entry entry : info.getHeaders().getAsList()) { + if (entry.getKey().equalsIgnoreCase("Content-Encoding")) { + return !entry.getValue().equalsIgnoreCase("identity"); + } + } + return false; + } + + @Nullable + private static String parseCookies(@Nullable List setCookieHeaders) { + if (setCookieHeaders == null || setCookieHeaders.isEmpty()) { + return null; + } + return TextUtils.join(";", setCookieHeaders); + } + + @Nullable + private static String getFirstHeader(Map> allHeaders, String headerName) { + @Nullable List headers = allHeaders.get(headerName); + return headers != null && !headers.isEmpty() ? headers.get(0) : null; + } + + // Copy as much as possible from the src buffer into dst buffer. + // Returns the number of bytes copied. + private static int copyByteBuffer(ByteBuffer src, ByteBuffer dst) { + int remaining = Math.min(src.remaining(), dst.remaining()); + int limit = src.limit(); + src.limit(src.position() + remaining); + dst.put(src); + src.limit(limit); + return remaining; + } + + /** + * A wrapper class that manages a {@link UrlRequest} and the {@link UrlRequestCallback} associated + * with that request. + */ + private static final class UrlRequestWrapper { + + private final UrlRequest urlRequest; + private final UrlRequestCallback urlRequestCallback; + + UrlRequestWrapper(UrlRequest urlRequest, UrlRequestCallback urlRequestCallback) { + this.urlRequest = urlRequest; + this.urlRequestCallback = urlRequestCallback; + } + + public void start() { + urlRequest.start(); + } + + public void read(ByteBuffer buffer) { + urlRequest.read(buffer); + } + + public void close() { + urlRequestCallback.close(); + urlRequest.cancel(); + } + + public UrlRequest.Callback getUrlRequestCallback() { + return urlRequestCallback; + } + + public int getStatus() throws InterruptedException { + final ConditionVariable conditionVariable = new ConditionVariable(); + final int[] statusHolder = new int[1]; + urlRequest.getStatus( + new UrlRequest.StatusListener() { + @Override + public void onStatus(int status) { + statusHolder[0] = status; + conditionVariable.open(); + } + }); + conditionVariable.block(); + return statusHolder[0]; + } + } + + private final class UrlRequestCallback implements UrlRequest.Callback { + + private volatile boolean isClosed = false; + + public void close() { + this.isClosed = true; + } + + @Override + public synchronized void onRedirectReceived( + UrlRequest request, UrlResponseInfo info, String newLocationUrl) { + if (isClosed) { + return; + } + DataSpec dataSpec = Assertions.checkNotNull(currentDataSpec); + int responseCode = info.getHttpStatusCode(); + if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) { + // The industry standard is to disregard POST redirects when the status code is 307 or + // 308. + if (responseCode == 307 || responseCode == 308) { + exception = + new InvalidResponseCodeException( + responseCode, + info.getHttpStatusText(), + /* cause= */ null, + info.getHeaders().getAsMap(), + dataSpec, + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + operation.open(); + return; + } + } + if (resetTimeoutOnRedirects) { + resetConnectTimeout(); + } + + boolean shouldKeepPost = + keepPostFor302Redirects + && dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST + && responseCode == 302; + + // request.followRedirect() transforms a POST request into a GET request, so if we want to + // keep it as a POST we need to fall through to the manual redirect logic below. + if (!shouldKeepPost && !handleSetCookieRequests) { + request.followRedirect(); + return; + } + + @Nullable + String cookieHeadersValue = + parseCookies(info.getHeaders().getAsMap().get(HttpHeaders.SET_COOKIE)); + if (!shouldKeepPost && TextUtils.isEmpty(cookieHeadersValue)) { + request.followRedirect(); + return; + } + + request.cancel(); + DataSpec redirectUrlDataSpec; + if (!shouldKeepPost && dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) { + // For POST redirects that aren't 307 or 308, the redirect is followed but request is + // transformed into a GET unless shouldKeepPost is true. + redirectUrlDataSpec = + dataSpec + .buildUpon() + .setUri(newLocationUrl) + .setHttpMethod(DataSpec.HTTP_METHOD_GET) + .setHttpBody(null) + .build(); + } else { + redirectUrlDataSpec = dataSpec.withUri(Uri.parse(newLocationUrl)); + } + if (!TextUtils.isEmpty(cookieHeadersValue)) { + Map requestHeaders = new HashMap<>(); + requestHeaders.putAll(dataSpec.httpRequestHeaders); + requestHeaders.put(HttpHeaders.COOKIE, cookieHeadersValue); + redirectUrlDataSpec = + redirectUrlDataSpec.buildUpon().setHttpRequestHeaders(requestHeaders).build(); + } + UrlRequestWrapper redirectUrlRequestWrapper; + try { + redirectUrlRequestWrapper = buildRequestWrapper(redirectUrlDataSpec); + } catch (IOException e) { + exception = e; + return; + } + if (currentUrlRequestWrapper != null) { + currentUrlRequestWrapper.close(); + } + currentUrlRequestWrapper = redirectUrlRequestWrapper; + currentUrlRequestWrapper.start(); + } + + @Override + public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) { + if (isClosed) { + return; + } + responseInfo = info; + operation.open(); + } + + @Override + public synchronized void onReadCompleted( + UrlRequest request, UrlResponseInfo info, ByteBuffer buffer) { + if (isClosed) { + return; + } + operation.open(); + } + + @Override + public synchronized void onSucceeded(UrlRequest request, UrlResponseInfo info) { + if (isClosed) { + return; + } + finished = true; + operation.open(); + } + + @Override + public synchronized void onFailed( + UrlRequest request, @Nullable UrlResponseInfo info, HttpException error) { + if (isClosed) { + return; + } + if (error instanceof NetworkException + && ((NetworkException) error).getErrorCode() + == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) { + exception = new UnknownHostException(); + } else { + exception = error; + } + operation.open(); + } + + @Override + public synchronized void onCanceled(UrlRequest request, @Nullable UrlResponseInfo info) { + // Do nothing + } + } +} diff --git a/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/package-info.java b/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/package-info.java new file mode 100644 index 0000000000..a013b97740 --- /dev/null +++ b/libraries/datasource_httpengine/src/main/java/androidx/media3/datasource/httpengine/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2023 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. + */ +@NonNullApi +package androidx.media3.datasource.httpengine; + +import androidx.media3.common.util.NonNullApi; diff --git a/libraries/datasource_httpengine/src/test/AndroidManifest.xml b/libraries/datasource_httpengine/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..2be4f3c1c1 --- /dev/null +++ b/libraries/datasource_httpengine/src/test/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/libraries/datasource_httpengine/src/test/java/androidx/media3/datasource/httpengine/ByteArrayUploadDataProviderTest.java b/libraries/datasource_httpengine/src/test/java/androidx/media3/datasource/httpengine/ByteArrayUploadDataProviderTest.java new file mode 100644 index 0000000000..e126005e22 --- /dev/null +++ b/libraries/datasource_httpengine/src/test/java/androidx/media3/datasource/httpengine/ByteArrayUploadDataProviderTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2023 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.datasource.httpengine; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.net.http.UploadDataSink; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.annotation.Config; + +/** Tests for {@link ByteArrayUploadDataProvider}. */ +@RunWith(AndroidJUnit4.class) +@Config(sdk = 34) +public final class ByteArrayUploadDataProviderTest { + + private static final byte[] TEST_DATA = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + + @Mock private UploadDataSink mockUploadDataSink; + private ByteBuffer byteBuffer; + private ByteArrayUploadDataProvider byteArrayUploadDataProvider; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + byteBuffer = ByteBuffer.allocate(TEST_DATA.length); + byteArrayUploadDataProvider = new ByteArrayUploadDataProvider(TEST_DATA); + } + + @Test + public void getLength() { + assertThat(byteArrayUploadDataProvider.getLength()).isEqualTo(TEST_DATA.length); + } + + @Test + public void readFullBuffer() throws IOException { + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertThat(byteBuffer.array()).isEqualTo(TEST_DATA); + } + + @Test + public void readPartialBuffer() throws IOException { + byte[] firstHalf = Arrays.copyOf(TEST_DATA, TEST_DATA.length / 2); + byte[] secondHalf = Arrays.copyOfRange(TEST_DATA, TEST_DATA.length / 2, TEST_DATA.length); + byteBuffer = ByteBuffer.allocate(TEST_DATA.length / 2); + // Read half of the data. + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertThat(byteBuffer.array()).isEqualTo(firstHalf); + + // Read the second half of the data. + byteBuffer.rewind(); + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertThat(byteBuffer.array()).isEqualTo(secondHalf); + verify(mockUploadDataSink, times(2)).onReadSucceeded(false); + } + + @Test + public void rewind() throws IOException { + // Read all the data. + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertThat(byteBuffer.array()).isEqualTo(TEST_DATA); + + // Rewind and make sure it can be read again. + byteBuffer.clear(); + byteArrayUploadDataProvider.rewind(mockUploadDataSink); + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertThat(byteBuffer.array()).isEqualTo(TEST_DATA); + verify(mockUploadDataSink).onRewindSucceeded(); + } +} diff --git a/libraries/datasource_httpengine/src/test/java/androidx/media3/datasource/httpengine/HttpEngineDataSourceTest.java b/libraries/datasource_httpengine/src/test/java/androidx/media3/datasource/httpengine/HttpEngineDataSourceTest.java new file mode 100644 index 0000000000..57bf5e9676 --- /dev/null +++ b/libraries/datasource_httpengine/src/test/java/androidx/media3/datasource/httpengine/HttpEngineDataSourceTest.java @@ -0,0 +1,1673 @@ +/* + * Copyright (C) 2023 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.datasource.httpengine; + +import static android.net.http.NetworkException.ERROR_HOSTNAME_NOT_RESOLVED; +import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.min; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.net.Uri; +import android.net.http.HeaderBlock; +import android.net.http.HttpEngine; +import android.net.http.NetworkException; +import android.net.http.UrlRequest; +import android.net.http.UrlResponseInfo; +import android.os.ConditionVariable; +import android.os.SystemClock; +import androidx.media3.common.C; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.HttpDataSource.HttpDataSourceException; +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException; +import androidx.media3.datasource.TransferListener; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowLooper; + +/** Tests for {@link HttpEngineDataSource}. */ +@RunWith(AndroidJUnit4.class) +@Config(sdk = 34) +public final class HttpEngineDataSourceTest { + + private static final int TEST_CONNECT_TIMEOUT_MS = 100; + private static final int TEST_READ_TIMEOUT_MS = 100; + private static final String TEST_URL = "http://google.com"; + private static final String TEST_CONTENT_TYPE = "test/test"; + private static final byte[] TEST_POST_BODY = Util.getUtf8Bytes("test post body"); + private static final long TEST_CONTENT_LENGTH = 16000L; + private static final int TEST_CONNECTION_STATUS = 5; + private static final int TEST_INVALID_CONNECTION_STATUS = -1; + + private DataSpec testDataSpec; + private DataSpec testPostDataSpec; + private DataSpec testHeadDataSpec; + private Map testResponseHeader; + private UrlResponseInfo testUrlResponseInfo; + + @Mock private UrlRequest.Builder mockUrlRequestBuilder; + @Mock private UrlRequest mockUrlRequest; + @Mock private TransferListener mockTransferListener; + @Mock private HttpEngine mockHttpEngine; + + private ExecutorService executorService; + private HttpEngineDataSource dataSourceUnderTest; + private boolean redirectCalled; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + Map defaultRequestProperties = new HashMap<>(); + defaultRequestProperties.put("defaultHeader1", "defaultValue1"); + defaultRequestProperties.put("defaultHeader2", "defaultValue2"); + + executorService = Executors.newSingleThreadExecutor(); + dataSourceUnderTest = + (HttpEngineDataSource) + new HttpEngineDataSource.Factory(mockHttpEngine, executorService) + .setConnectionTimeoutMs(TEST_CONNECT_TIMEOUT_MS) + .setReadTimeoutMs(TEST_READ_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setDefaultRequestProperties(defaultRequestProperties) + .createDataSource(); + dataSourceUnderTest.addTransferListener(mockTransferListener); + when(mockHttpEngine.newUrlRequestBuilder( + anyString(), any(Executor.class), any(UrlRequest.Callback.class))) + .thenReturn(mockUrlRequestBuilder); + when(mockUrlRequestBuilder.setPriority(anyInt())).thenReturn(mockUrlRequestBuilder); + when(mockUrlRequestBuilder.setDirectExecutorAllowed(anyBoolean())) + .thenReturn(mockUrlRequestBuilder); + when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest); + mockStatusResponse(); + + testDataSpec = new DataSpec(Uri.parse(TEST_URL)); + testPostDataSpec = + new DataSpec.Builder() + .setUri(TEST_URL) + .setHttpMethod(DataSpec.HTTP_METHOD_POST) + .setHttpBody(TEST_POST_BODY) + .build(); + testHeadDataSpec = + new DataSpec.Builder().setUri(TEST_URL).setHttpMethod(DataSpec.HTTP_METHOD_HEAD).build(); + testResponseHeader = new HashMap<>(); + testResponseHeader.put("Content-Type", TEST_CONTENT_TYPE); + // This value can be anything since the DataSpec is unset. + testResponseHeader.put("Content-Length", Long.toString(TEST_CONTENT_LENGTH)); + testUrlResponseInfo = createUrlResponseInfo(/* statusCode= */ 200); + } + + @After + public void tearDown() { + executorService.shutdown(); + } + + private UrlResponseInfo createUrlResponseInfo(int statusCode) { + return createUrlResponseInfoWithUrl(TEST_URL, statusCode); + } + + private UrlResponseInfo createUrlResponseInfoWithUrl(String url, int statusCode) { + ArrayList> responseHeaderList = new ArrayList<>(); + Map> responseHeaderMap = new HashMap<>(); + for (Map.Entry entry : testResponseHeader.entrySet()) { + responseHeaderList.add(entry); + responseHeaderMap.put(entry.getKey(), Collections.singletonList(entry.getValue())); + } + HeaderBlock mockHeaderBlock = mock(HeaderBlock.class); + when(mockHeaderBlock.getAsMap()).thenReturn(responseHeaderMap); + when(mockHeaderBlock.getAsList()).thenReturn(responseHeaderList); + + return new UrlResponseInfo() { + @Override + public String getUrl() { + return url; + } + + @Override + public List getUrlChain() { + return Collections.singletonList(url); + } + + @Override + public int getHttpStatusCode() { + return statusCode; + } + + @Override + public String getHttpStatusText() { + return null; + } + + @Override + public HeaderBlock getHeaders() { + return mockHeaderBlock; + } + + @Override + public boolean wasCached() { + return false; + } + + @Override + public String getNegotiatedProtocol() { + return null; + } + + @Override + public long getReceivedByteCount() { + return 0; + } + }; + } + + @Test + public void openingTwiceThrows() throws HttpDataSourceException { + mockResponseStartSuccess(); + dataSourceUnderTest.open(testDataSpec); + try { + dataSourceUnderTest.open(testDataSpec); + fail("Expected IllegalStateException."); + } catch (IllegalStateException e) { + // Expected. + } + } + + @Test + public void callbackFromPreviousRequest() throws HttpDataSourceException { + mockResponseStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + UrlRequest.Callback previousRequestCallback = + dataSourceUnderTest.getCurrentUrlRequestCallback(); + dataSourceUnderTest.close(); + // Prepare a mock UrlRequest to be used in the second open() call. + final UrlRequest mockUrlRequest2 = mock(UrlRequest.class); + when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest2); + doAnswer( + invocation -> { + // Invoke the callback for the previous request. + previousRequestCallback.onFailed( + mockUrlRequest, + testUrlResponseInfo, + createNetworkException( + /* errorCode= */ Integer.MAX_VALUE, + /* cause= */ new IllegalArgumentException())); + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onResponseStarted(mockUrlRequest2, testUrlResponseInfo); + return null; + }) + .when(mockUrlRequest2) + .start(); + dataSourceUnderTest.open(testDataSpec); + } + + @Test + public void requestStartCalled() throws HttpDataSourceException { + mockResponseStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockHttpEngine) + .newUrlRequestBuilder(eq(TEST_URL), any(Executor.class), any(UrlRequest.Callback.class)); + verify(mockUrlRequest).start(); + } + + @Test + public void requestSetsRangeHeader() throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000); + mockResponseStartSuccess(); + mockReadSuccess(0, 1000); + + dataSourceUnderTest.open(testDataSpec); + // The header value to add is current position to current position + length - 1. + verify(mockUrlRequestBuilder).addHeader("Range", "bytes=1000-5999"); + } + + @Test + public void requestHeadersSet() throws HttpDataSourceException { + Map headersSet = new HashMap<>(); + doAnswer( + (invocation) -> { + String key = invocation.getArgument(0); + String value = invocation.getArgument(1); + headersSet.put(key, value); + return null; + }) + .when(mockUrlRequestBuilder) + .addHeader(ArgumentMatchers.anyString(), ArgumentMatchers.anyString()); + + dataSourceUnderTest.setRequestProperty("defaultHeader2", "dataSourceOverridesDefault"); + dataSourceUnderTest.setRequestProperty("dataSourceHeader1", "dataSourceValue1"); + dataSourceUnderTest.setRequestProperty("dataSourceHeader2", "dataSourceValue2"); + + Map dataSpecRequestProperties = new HashMap<>(); + dataSpecRequestProperties.put("defaultHeader3", "dataSpecOverridesAll"); + dataSpecRequestProperties.put("dataSourceHeader2", "dataSpecOverridesDataSource"); + dataSpecRequestProperties.put("dataSpecHeader1", "dataSpecValue1"); + + testDataSpec = + new DataSpec.Builder() + .setUri(TEST_URL) + .setHttpRequestHeaders(dataSpecRequestProperties) + .build(); + mockResponseStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + + assertThat(headersSet.get("defaultHeader1")).isEqualTo("defaultValue1"); + assertThat(headersSet.get("defaultHeader2")).isEqualTo("dataSourceOverridesDefault"); + assertThat(headersSet.get("defaultHeader3")).isEqualTo("dataSpecOverridesAll"); + assertThat(headersSet.get("dataSourceHeader1")).isEqualTo("dataSourceValue1"); + assertThat(headersSet.get("dataSourceHeader2")).isEqualTo("dataSpecOverridesDataSource"); + assertThat(headersSet.get("dataSpecHeader1")).isEqualTo("dataSpecValue1"); + + verify(mockUrlRequest).start(); + } + + @Test + public void requestOpen() throws HttpDataSourceException { + mockResponseStartSuccess(); + assertThat(dataSourceUnderTest.open(testDataSpec)).isEqualTo(TEST_CONTENT_LENGTH); + verify(mockTransferListener) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + + @Test + public void requestOpenGzippedCompressedReturnsDataSpecLength() throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 5000); + testResponseHeader.put("Content-Encoding", "gzip"); + testResponseHeader.put("Content-Length", Long.toString(50L)); + mockResponseStartSuccess(); + + assertThat(dataSourceUnderTest.open(testDataSpec)).isEqualTo(5000 /* contentLength */); + verify(mockTransferListener) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + + @Test + public void requestOpenFail() { + mockResponseStartFailure( + /* errorCode= */ Integer.MAX_VALUE, /* cause= */ new IllegalArgumentException()); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + // Check for connection not automatically closed. + assertThat(e).hasCauseThat().isNotInstanceOf(UnknownHostException.class); + verify(mockUrlRequest, never()).cancel(); + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + } + + @Test + public void open_ifBodyIsSetWithoutContentTypeHeader_fails() { + testDataSpec = + new DataSpec.Builder() + .setUri(TEST_URL) + .setHttpMethod(DataSpec.HTTP_METHOD_POST) + .setHttpBody(new byte[1024]) + .setPosition(200) + .setLength(1024) + .setKey("key") + .build(); + + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (IOException expected) { + // Expected + } + } + + @Test + public void requestOpenFailDueToDnsFailure() { + mockResponseStartFailure( + /* errorCode= */ ERROR_HOSTNAME_NOT_RESOLVED, /* cause= */ new UnknownHostException()); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + // Check for connection not automatically closed. + assertThat(e).hasCauseThat().isInstanceOf(UnknownHostException.class); + verify(mockUrlRequest, never()).cancel(); + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + } + + @Test + public void requestOpen_withNon2xxResponseCode_throwsInvalidResponseCodeExceptionWithBody() + throws Exception { + mockResponseStartSuccess(); + // Use a size larger than HttpEngineDataSource.READ_BUFFER_SIZE_BYTES + int responseLength = 40 * 1024; + mockReadSuccess(/* position= */ 0, /* length= */ responseLength); + testUrlResponseInfo = createUrlResponseInfo(/* statusCode= */ 500); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("InvalidResponseCodeException expected"); + } catch (InvalidResponseCodeException e) { + assertThat(e.responseBody).isEqualTo(buildTestDataArray(0, responseLength)); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + } + + @Test + public void + requestOpen_withNon2xxResponseCode_andRequestBodyReadFailure_throwsInvalidResponseCodeExceptionWithoutBody() + throws Exception { + mockResponseStartSuccess(); + mockReadFailure(); + testUrlResponseInfo = createUrlResponseInfo(/* statusCode= */ 500); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("InvalidResponseCodeException expected"); + } catch (InvalidResponseCodeException e) { + assertThat(e.responseBody).isEmpty(); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + } + + @Test + public void requestOpenValidatesContentTypePredicate() { + mockResponseStartSuccess(); + + ArrayList testedContentTypes = new ArrayList<>(); + dataSourceUnderTest = + (HttpEngineDataSource) + new HttpEngineDataSource.Factory(mockHttpEngine, executorService) + .setContentTypePredicate( + (String input) -> { + testedContentTypes.add(input); + return false; + }) + .createDataSource(); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + assertThat(e).isInstanceOf(HttpDataSource.InvalidContentTypeException.class); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + assertThat(testedContentTypes).hasSize(1); + assertThat(testedContentTypes.get(0)).isEqualTo(TEST_CONTENT_TYPE); + } + } + + @Test + public void postRequestOpen() throws HttpDataSourceException { + mockResponseStartSuccess(); + + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + assertThat(dataSourceUnderTest.open(testPostDataSpec)).isEqualTo(TEST_CONTENT_LENGTH); + verify(mockTransferListener) + .onTransferStart(dataSourceUnderTest, testPostDataSpec, /* isNetwork= */ true); + } + + @Test + public void postRequestOpenValidatesContentType() { + mockResponseStartSuccess(); + + try { + dataSourceUnderTest.open(testPostDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + verify(mockUrlRequest, never()).start(); + } + } + + @Test + public void postRequestOpenRejects307Redirects() { + mockResponseStartSuccess(); + mockResponseStartRedirect(); + + try { + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + dataSourceUnderTest.open(testPostDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + verify(mockUrlRequest, never()).followRedirect(); + } + } + + @Test + public void headRequestOpen() throws HttpDataSourceException { + mockResponseStartSuccess(); + dataSourceUnderTest.open(testHeadDataSpec); + verify(mockTransferListener) + .onTransferStart(dataSourceUnderTest, testHeadDataSpec, /* isNetwork= */ true); + dataSourceUnderTest.close(); + } + + @Test + public void requestReadTwice() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8)); + assertThat(bytesRead).isEqualTo(8); + + returnedBuffer = new byte[8]; + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(8, 8)); + assertThat(bytesRead).isEqualTo(8); + + // Should have only called read on the HttpEngine once. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(2)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + } + + @Test + public void secondRequestNoContentLength() throws HttpDataSourceException { + mockResponseStartSuccess(); + testResponseHeader.put("Content-Length", Long.toString(1L)); + mockReadSuccess(0, 16); + + // First request. + dataSourceUnderTest.open(testDataSpec); + byte[] returnedBuffer = new byte[8]; + dataSourceUnderTest.read(returnedBuffer, 0, 1); + dataSourceUnderTest.close(); + + testResponseHeader.remove("Content-Length"); + mockReadSuccess(0, 16); + + // Second request. + dataSourceUnderTest.open(testDataSpec); + returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertThat(bytesRead).isEqualTo(10); + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertThat(bytesRead).isEqualTo(6); + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertThat(bytesRead).isEqualTo(C.RESULT_END_OF_INPUT); + } + + @Test + public void readWithOffset() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8); + assertThat(bytesRead).isEqualTo(8); + assertThat(returnedBuffer).isEqualTo(prefixZeros(buildTestDataArray(0, 8), 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + } + + @Test + public void rangeRequestWith206Response() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(1000, 5000); + testUrlResponseInfo = createUrlResponseInfo(206); // Server supports range requests. + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000); + + long length = dataSourceUnderTest.open(testDataSpec); + assertThat(length).isEqualTo(5000); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); + assertThat(bytesRead).isEqualTo(16); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void rangeRequestWith200Response() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 7000); + testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests. + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000); + + long length = dataSourceUnderTest.open(testDataSpec); + assertThat(length).isEqualTo(5000); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); + assertThat(bytesRead).isEqualTo(16); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void unboundedRangeRequestWith200Response() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, (int) TEST_CONTENT_LENGTH); + testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests. + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, C.LENGTH_UNSET); + + long length = dataSourceUnderTest.open(testDataSpec); + assertThat(length).isEqualTo(TEST_CONTENT_LENGTH - 1000); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); + assertThat(bytesRead).isEqualTo(16); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void readWithUnsetLength() throws HttpDataSourceException { + testResponseHeader.remove("Content-Length"); + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8); + assertThat(returnedBuffer).isEqualTo(prefixZeros(buildTestDataArray(0, 8), 16)); + assertThat(bytesRead).isEqualTo(8); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + } + + @Test + public void readReturnsWhatItCan() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[24]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 24); + assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(0, 16), 24)); + assertThat(bytesRead).isEqualTo(16); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void closedMeansClosed() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + int bytesRead = 0; + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8)); + assertThat(bytesRead).isEqualTo(8); + + dataSourceUnderTest.close(); + verify(mockTransferListener) + .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + + try { + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + fail(); + } catch (IllegalStateException e) { + // Expected. + } + + // 16 bytes were attempted but only 8 should have been successfully read. + assertThat(bytesRead).isEqualTo(8); + } + + @Test + public void overread() throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 16); + testResponseHeader.put("Content-Length", Long.toString(16L)); + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(bytesRead).isEqualTo(8); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8)); + + // The current buffer is kept if not completely consumed by DataSource reader. + returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 6); + assertThat(bytesRead).isEqualTo(14); + assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(8, 6), 8)); + + // 2 bytes left at this point. + returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(bytesRead).isEqualTo(16); + assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(14, 2), 8)); + + // Should have only called read on the HttpEngine once. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 2); + + // Now we already returned the 16 bytes initially asked. + // Try to read again even though all requested 16 bytes are already returned. + // Return C.RESULT_END_OF_INPUT + returnedBuffer = new byte[16]; + int bytesOverRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); + assertThat(bytesOverRead).isEqualTo(C.RESULT_END_OF_INPUT); + assertThat(returnedBuffer).isEqualTo(new byte[16]); + // C.RESULT_END_OF_INPUT should not be reported though the TransferListener. + verify(mockTransferListener, never()) + .onBytesTransferred( + dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, C.RESULT_END_OF_INPUT); + // There should still be only one call to read on the HttpEngine. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + assertThat(bytesRead).isEqualTo(16); + } + + @Test + public void requestReadByteBufferTwice() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8); + int bytesRead = dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(8); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8)); + + // Use a wrapped ByteBuffer instead of direct for coverage. + returnedBuffer.rewind(); + bytesRead = dataSourceUnderTest.read(returnedBuffer); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(8, 8)); + assertThat(bytesRead).isEqualTo(8); + + // Separate HttpEngine calls for each read. + verify(mockUrlRequest, times(2)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(2)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + } + + @Test + public void requestIntermixRead() throws HttpDataSourceException { + mockResponseStartSuccess(); + // Chunking reads into parts 6, 7, 8, 9. + mockReadSuccess(0, 30); + + dataSourceUnderTest.open(testDataSpec); + + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(6); + int bytesRead = dataSourceUnderTest.read(returnedBuffer); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 6)); + assertThat(bytesRead).isEqualTo(6); + + byte[] returnedBytes = new byte[7]; + bytesRead += dataSourceUnderTest.read(returnedBytes, 0, 7); + assertThat(returnedBytes).isEqualTo(buildTestDataArray(6, 7)); + assertThat(bytesRead).isEqualTo(6 + 7); + + returnedBuffer = ByteBuffer.allocateDirect(8); + bytesRead += dataSourceUnderTest.read(returnedBuffer); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(13, 8)); + assertThat(bytesRead).isEqualTo(6 + 7 + 8); + + returnedBytes = new byte[9]; + bytesRead += dataSourceUnderTest.read(returnedBytes, 0, 9); + assertThat(returnedBytes).isEqualTo(buildTestDataArray(21, 9)); + assertThat(bytesRead).isEqualTo(6 + 7 + 8 + 9); + + // First ByteBuffer call. The first byte[] call populates enough bytes for the rest. + verify(mockUrlRequest, times(2)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 7); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 9); + } + + @Test + public void secondRequestNoContentLengthReadByteBuffer() throws HttpDataSourceException { + mockResponseStartSuccess(); + testResponseHeader.put("Content-Length", Long.toString(1L)); + mockReadSuccess(0, 16); + + // First request. + dataSourceUnderTest.open(testDataSpec); + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8); + dataSourceUnderTest.read(returnedBuffer); + dataSourceUnderTest.close(); + + testResponseHeader.remove("Content-Length"); + mockReadSuccess(0, 16); + + // Second request. + dataSourceUnderTest.open(testDataSpec); + returnedBuffer = ByteBuffer.allocateDirect(16); + returnedBuffer.limit(10); + int bytesRead = dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(10); + returnedBuffer.limit(returnedBuffer.capacity()); + bytesRead = dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(6); + returnedBuffer.rewind(); + bytesRead = dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(C.RESULT_END_OF_INPUT); + } + + @Test + public void rangeRequestWith206ResponseReadByteBuffer() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(1000, 5000); + testUrlResponseInfo = createUrlResponseInfo(206); // Server supports range requests. + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000); + + long length = dataSourceUnderTest.open(testDataSpec); + assertThat(length).isEqualTo(5000); + + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16); + int bytesRead = dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(16); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(1000, 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void rangeRequestWith200ResponseReadByteBuffer() throws HttpDataSourceException { + // Tests for skipping bytes. + mockResponseStartSuccess(); + mockReadSuccess(0, 7000); + testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests. + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000); + + long length = dataSourceUnderTest.open(testDataSpec); + assertThat(length).isEqualTo(5000); + + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16); + int bytesRead = dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(16); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(1000, 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void readByteBufferWithUnsetLength() throws HttpDataSourceException { + testResponseHeader.remove("Content-Length"); + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16); + returnedBuffer.limit(8); + int bytesRead = dataSourceUnderTest.read(returnedBuffer); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8)); + assertThat(bytesRead).isEqualTo(8); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + } + + @Test + public void readByteBufferReturnsWhatItCan() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(24); + int bytesRead = dataSourceUnderTest.read(returnedBuffer); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 16)); + assertThat(bytesRead).isEqualTo(16); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void overreadByteBuffer() throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 16); + testResponseHeader.put("Content-Length", Long.toString(16L)); + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8); + int bytesRead = dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(8); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8)); + + // The current buffer is kept if not completely consumed by DataSource reader. + returnedBuffer = ByteBuffer.allocateDirect(6); + bytesRead += dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(14); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(8, 6)); + + // 2 bytes left at this point. + returnedBuffer = ByteBuffer.allocateDirect(8); + bytesRead += dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesRead).isEqualTo(16); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(14, 2)); + + // Called on each. + verify(mockUrlRequest, times(3)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 2); + + // Now we already returned the 16 bytes initially asked. + // Try to read again even though all requested 16 bytes are already returned. + // Return C.RESULT_END_OF_INPUT + returnedBuffer = ByteBuffer.allocateDirect(16); + int bytesOverRead = dataSourceUnderTest.read(returnedBuffer); + assertThat(bytesOverRead).isEqualTo(C.RESULT_END_OF_INPUT); + assertThat(returnedBuffer.position()).isEqualTo(0); + // C.RESULT_END_OF_INPUT should not be reported though the TransferListener. + verify(mockTransferListener, never()) + .onBytesTransferred( + dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, C.RESULT_END_OF_INPUT); + // Number of calls to the HttpEngine should not have increased. + verify(mockUrlRequest, times(3)).read(any(ByteBuffer.class)); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + assertThat(bytesRead).isEqualTo(16); + } + + @Test + public void closedMeansClosedReadByteBuffer() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + int bytesRead = 0; + dataSourceUnderTest.open(testDataSpec); + + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16); + returnedBuffer.limit(8); + bytesRead += dataSourceUnderTest.read(returnedBuffer); + returnedBuffer.flip(); + assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8)); + assertThat(bytesRead).isEqualTo(8); + + dataSourceUnderTest.close(); + verify(mockTransferListener) + .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + + try { + bytesRead += dataSourceUnderTest.read(returnedBuffer); + fail(); + } catch (IllegalStateException e) { + // Expected. + } + + // 16 bytes were attempted but only 8 should have been successfully read. + assertThat(bytesRead).isEqualTo(8); + } + + @Test + public void connectTimeout() throws InterruptedException { + long startTimeMs = SystemClock.elapsedRealtime(); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e).isInstanceOf(HttpEngineDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class); + assertThat(((HttpEngineDataSource.OpenException) e).httpEngineConnectionStatus) + .isEqualTo(TEST_CONNECTION_STATUS); + timedOutLatch.countDown(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertNotCountedDown(timedOutLatch); + // We should still be trying to open as we approach the timeout. + setSystemClockInMsAndTriggerPendingMessages( + /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1); + assertNotCountedDown(timedOutLatch); + // Now we timeout. + setSystemClockInMsAndTriggerPendingMessages( + /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS + 10); + timedOutLatch.await(); + + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + + @Test + public void connectInterrupted() throws InterruptedException { + long startTimeMs = SystemClock.elapsedRealtime(); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + + Thread thread = + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e).isInstanceOf(HttpEngineDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); + assertThat(((HttpEngineDataSource.OpenException) e).httpEngineConnectionStatus) + .isEqualTo(TEST_INVALID_CONNECTION_STATUS); + timedOutLatch.countDown(); + } + } + }; + thread.start(); + startCondition.block(); + + // We should still be trying to open. + assertNotCountedDown(timedOutLatch); + // We should still be trying to open as we approach the timeout. + setSystemClockInMsAndTriggerPendingMessages( + /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1); + assertNotCountedDown(timedOutLatch); + // Now we interrupt. + thread.interrupt(); + timedOutLatch.await(); + + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + + @Test + public void connectResponseBeforeTimeout() throws Exception { + long startTimeMs = SystemClock.elapsedRealtime(); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final CountDownLatch openLatch = new CountDownLatch(1); + + AtomicReference exceptionOnTestThread = new AtomicReference<>(); + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + } catch (HttpDataSourceException e) { + exceptionOnTestThread.set(e); + } finally { + openLatch.countDown(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertNotCountedDown(openLatch); + // We should still be trying to open as we approach the timeout. + setSystemClockInMsAndTriggerPendingMessages( + /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1); + assertNotCountedDown(openLatch); + // The response arrives just in time. + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onResponseStarted(mockUrlRequest, testUrlResponseInfo); + openLatch.await(); + assertThat(exceptionOnTestThread.get()).isNull(); + } + + @Test + public void redirectIncreasesConnectionTimeout() throws Exception { + long startTimeMs = SystemClock.elapsedRealtime(); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + final AtomicInteger openExceptions = new AtomicInteger(0); + + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e).isInstanceOf(HttpEngineDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class); + openExceptions.getAndIncrement(); + timedOutLatch.countDown(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertNotCountedDown(timedOutLatch); + // We should still be trying to open as we approach the timeout. + setSystemClockInMsAndTriggerPendingMessages( + /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1); + assertNotCountedDown(timedOutLatch); + // A redirect arrives just in time. + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onRedirectReceived(mockUrlRequest, testUrlResponseInfo, "RandomRedirectedUrl1"); + + long newTimeoutMs = 2 * TEST_CONNECT_TIMEOUT_MS - 1; + setSystemClockInMsAndTriggerPendingMessages(/* nowMs= */ startTimeMs + newTimeoutMs - 1); + // We should still be trying to open as we approach the new timeout. + assertNotCountedDown(timedOutLatch); + // A redirect arrives just in time. + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onRedirectReceived(mockUrlRequest, testUrlResponseInfo, "RandomRedirectedUrl2"); + + newTimeoutMs = 3 * TEST_CONNECT_TIMEOUT_MS - 2; + setSystemClockInMsAndTriggerPendingMessages(/* nowMs= */ startTimeMs + newTimeoutMs - 1); + // We should still be trying to open as we approach the new timeout. + assertNotCountedDown(timedOutLatch); + // Now we timeout. + setSystemClockInMsAndTriggerPendingMessages(/* nowMs= */ startTimeMs + newTimeoutMs + 10); + timedOutLatch.await(); + + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + assertThat(openExceptions.get()).isEqualTo(1); + } + + @Test + public void redirectParseAndAttachCookie_dataSourceDoesNotHandleSetCookie_followsRedirect() + throws HttpDataSourceException { + mockSingleRedirectSuccess(/* responseCode= */ 300); + mockFollowRedirectSuccess(); + + testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video"); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequest).followRedirect(); + } + + @Test + public void + testRedirectParseAndAttachCookie_dataSourceHandlesSetCookie_andPreservesOriginalRequestHeaders() + throws HttpDataSourceException { + dataSourceUnderTest = + (HttpEngineDataSource) + new HttpEngineDataSource.Factory(mockHttpEngine, executorService) + .setConnectionTimeoutMs(TEST_CONNECT_TIMEOUT_MS) + .setReadTimeoutMs(TEST_READ_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setHandleSetCookieRequests(true) + .createDataSource(); + dataSourceUnderTest.addTransferListener(mockTransferListener); + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + + mockSingleRedirectSuccess(/* responseCode= */ 300); + + testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video"); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequestBuilder, never()).addHeader(eq("Range"), any(String.class)); + verify(mockUrlRequestBuilder, times(2)).addHeader("Content-Type", TEST_CONTENT_TYPE); + verify(mockUrlRequest, never()).followRedirect(); + verify(mockUrlRequest, times(2)).start(); + } + + @Test + public void + testRedirectParseAndAttachCookie_dataSourceHandlesSetCookie_andPreservesOriginalRequestHeadersIncludingByteRangeHeader() + throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000); + dataSourceUnderTest = + (HttpEngineDataSource) + new HttpEngineDataSource.Factory(mockHttpEngine, executorService) + .setConnectionTimeoutMs(TEST_CONNECT_TIMEOUT_MS) + .setReadTimeoutMs(TEST_READ_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setHandleSetCookieRequests(true) + .createDataSource(); + dataSourceUnderTest.addTransferListener(mockTransferListener); + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + + mockSingleRedirectSuccess(/* responseCode= */ 300); + mockReadSuccess(0, 1000); + + testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video"); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequestBuilder, times(2)).addHeader("Range", "bytes=1000-5999"); + verify(mockUrlRequestBuilder, times(2)).addHeader("Content-Type", TEST_CONTENT_TYPE); + verify(mockUrlRequest, never()).followRedirect(); + verify(mockUrlRequest, times(2)).start(); + } + + @Test + public void redirectNoSetCookieFollowsRedirect() throws HttpDataSourceException { + mockSingleRedirectSuccess(/* responseCode= */ 300); + mockFollowRedirectSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequest).followRedirect(); + } + + @Test + public void redirectNoSetCookieFollowsRedirect_dataSourceHandlesSetCookie() + throws HttpDataSourceException { + dataSourceUnderTest = + (HttpEngineDataSource) + new HttpEngineDataSource.Factory(mockHttpEngine, executorService) + .setConnectionTimeoutMs(TEST_CONNECT_TIMEOUT_MS) + .setReadTimeoutMs(TEST_READ_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setHandleSetCookieRequests(true) + .createDataSource(); + dataSourceUnderTest.addTransferListener(mockTransferListener); + mockSingleRedirectSuccess(/* responseCode= */ 300); + mockFollowRedirectSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequest).followRedirect(); + } + + @Test + public void redirectPostFollowRedirect() throws HttpDataSourceException { + mockSingleRedirectSuccess(/* responseCode= */ 302); + mockFollowRedirectSuccess(); + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + + dataSourceUnderTest.open(testPostDataSpec); + + verify(mockUrlRequest).followRedirect(); + } + + @Test + public void redirect302ChangesPostToGet() throws HttpDataSourceException { + dataSourceUnderTest = + (HttpEngineDataSource) + new HttpEngineDataSource.Factory(mockHttpEngine, executorService) + .setConnectionTimeoutMs(TEST_CONNECT_TIMEOUT_MS) + .setReadTimeoutMs(TEST_READ_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setKeepPostFor302Redirects(false) + .setHandleSetCookieRequests(true) + .createDataSource(); + mockSingleRedirectSuccess(/* responseCode= */ 302); + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video"); + + dataSourceUnderTest.open(testPostDataSpec); + + verify(mockUrlRequest, never()).followRedirect(); + ArgumentCaptor methodCaptor = ArgumentCaptor.forClass(String.class); + verify(mockUrlRequestBuilder, times(2)).setHttpMethod(methodCaptor.capture()); + assertThat(methodCaptor.getAllValues()).containsExactly("POST", "GET").inOrder(); + } + + @Test + public void redirectKeeps302Post() throws HttpDataSourceException { + dataSourceUnderTest = + (HttpEngineDataSource) + new HttpEngineDataSource.Factory(mockHttpEngine, executorService) + .setConnectionTimeoutMs(TEST_CONNECT_TIMEOUT_MS) + .setReadTimeoutMs(TEST_READ_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setKeepPostFor302Redirects(true) + .createDataSource(); + mockSingleRedirectSuccess(/* responseCode= */ 302); + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + + dataSourceUnderTest.open(testPostDataSpec); + + verify(mockUrlRequest, never()).followRedirect(); + ArgumentCaptor methodCaptor = ArgumentCaptor.forClass(String.class); + verify(mockUrlRequestBuilder, times(2)).setHttpMethod(methodCaptor.capture()); + assertThat(methodCaptor.getAllValues()).containsExactly("POST", "POST").inOrder(); + ArgumentCaptor postBodyCaptor = + ArgumentCaptor.forClass(ByteArrayUploadDataProvider.class); + verify(mockUrlRequestBuilder, times(2)).setUploadDataProvider(postBodyCaptor.capture(), any()); + assertThat(postBodyCaptor.getAllValues().get(0).getLength()).isEqualTo(TEST_POST_BODY.length); + assertThat(postBodyCaptor.getAllValues().get(1).getLength()).isEqualTo(TEST_POST_BODY.length); + } + + @Test + public void exceptionFromTransferListener() throws HttpDataSourceException { + mockResponseStartSuccess(); + + // Make mockTransferListener throw an exception in HttpEngineDataSource.close(). Ensure that + // the subsequent open() call succeeds. + doThrow(new NullPointerException()) + .when(mockTransferListener) + .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + dataSourceUnderTest.open(testDataSpec); + try { + dataSourceUnderTest.close(); + fail("NullPointerException expected"); + } catch (NullPointerException e) { + // Expected. + } + // Open should return successfully. + dataSourceUnderTest.open(testDataSpec); + } + + @Test + public void readFailure() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadFailure(); + + dataSourceUnderTest.open(testDataSpec); + byte[] returnedBuffer = new byte[8]; + try { + dataSourceUnderTest.read(returnedBuffer, 0, 8); + fail("dataSourceUnderTest.read() returned, but IOException expected"); + } catch (IOException e) { + // Expected. + } + } + + @Test + public void readByteBufferFailure() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadFailure(); + + dataSourceUnderTest.open(testDataSpec); + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8); + try { + dataSourceUnderTest.read(returnedBuffer); + fail("dataSourceUnderTest.read() returned, but IOException expected"); + } catch (IOException e) { + // Expected. + } + } + + @Test + public void readNonDirectedByteBufferFailure() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadFailure(); + + dataSourceUnderTest.open(testDataSpec); + byte[] returnedBuffer = new byte[8]; + try { + dataSourceUnderTest.read(ByteBuffer.wrap(returnedBuffer)); + fail("dataSourceUnderTest.read() returned, but IllegalArgumentException expected"); + } catch (IllegalArgumentException e) { + // Expected. + } + } + + @Test + public void readInterrupted() throws HttpDataSourceException, InterruptedException { + mockResponseStartSuccess(); + dataSourceUnderTest.open(testDataSpec); + + final ConditionVariable startCondition = buildReadStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + byte[] returnedBuffer = new byte[8]; + Thread thread = + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.read(returnedBuffer, 0, 8); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); + timedOutLatch.countDown(); + } + } + }; + thread.start(); + startCondition.block(); + + assertNotCountedDown(timedOutLatch); + // Now we interrupt. + thread.interrupt(); + timedOutLatch.await(); + } + + @Test + public void readByteBufferInterrupted() throws HttpDataSourceException, InterruptedException { + mockResponseStartSuccess(); + dataSourceUnderTest.open(testDataSpec); + + final ConditionVariable startCondition = buildReadStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8); + Thread thread = + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.read(returnedBuffer); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); + timedOutLatch.countDown(); + } + } + }; + thread.start(); + startCondition.block(); + + assertNotCountedDown(timedOutLatch); + // Now we interrupt. + thread.interrupt(); + timedOutLatch.await(); + } + + @Test + public void allowDirectExecutor() throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL)); + mockResponseStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder).setDirectExecutorAllowed(true); + } + + // Helper methods. + + private void mockStatusResponse() { + doAnswer( + invocation -> { + UrlRequest.StatusListener statusListener = + (UrlRequest.StatusListener) invocation.getArguments()[0]; + statusListener.onStatus(TEST_CONNECTION_STATUS); + return null; + }) + .when(mockUrlRequest) + .getStatus(any(UrlRequest.StatusListener.class)); + } + + private void mockResponseStartSuccess() { + doAnswer( + invocation -> { + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onResponseStarted(mockUrlRequest, testUrlResponseInfo); + return null; + }) + .when(mockUrlRequest) + .start(); + } + + private void mockResponseStartRedirect() { + doAnswer( + invocation -> { + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onRedirectReceived( + mockUrlRequest, + createUrlResponseInfo(307), // statusCode + "http://redirect.location.com"); + return null; + }) + .when(mockUrlRequest) + .start(); + } + + private void mockSingleRedirectSuccess(int responseCode) { + doAnswer( + invocation -> { + if (!redirectCalled) { + redirectCalled = true; + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onRedirectReceived( + mockUrlRequest, + createUrlResponseInfoWithUrl("http://example.com/video", responseCode), + "http://example.com/video/redirect"); + } else { + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onResponseStarted(mockUrlRequest, testUrlResponseInfo); + } + return null; + }) + .when(mockUrlRequest) + .start(); + } + + private void mockFollowRedirectSuccess() { + doAnswer( + invocation -> { + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onResponseStarted(mockUrlRequest, testUrlResponseInfo); + return null; + }) + .when(mockUrlRequest) + .followRedirect(); + } + + private void mockResponseStartFailure(int errorCode, Throwable cause) { + doAnswer( + invocation -> { + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onFailed( + mockUrlRequest, + createUrlResponseInfo(500), // statusCode + createNetworkException(errorCode, cause)); + return null; + }) + .when(mockUrlRequest) + .start(); + } + + private void mockReadSuccess(int position, int length) { + final int[] positionAndRemaining = new int[] {position, length}; + doAnswer( + invocation -> { + if (positionAndRemaining[1] == 0) { + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onSucceeded(mockUrlRequest, testUrlResponseInfo); + } else { + ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0]; + int readLength = min(positionAndRemaining[1], inputBuffer.remaining()); + inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength)); + positionAndRemaining[0] += readLength; + positionAndRemaining[1] -= readLength; + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onReadCompleted(mockUrlRequest, testUrlResponseInfo, inputBuffer); + } + return null; + }) + .when(mockUrlRequest) + .read(any(ByteBuffer.class)); + } + + private void mockReadFailure() { + doAnswer( + invocation -> { + dataSourceUnderTest + .getCurrentUrlRequestCallback() + .onFailed( + mockUrlRequest, + createUrlResponseInfo(500), // statusCode + createNetworkException( + /* errorCode= */ Integer.MAX_VALUE, + /* cause= */ new IllegalArgumentException())); + return null; + }) + .when(mockUrlRequest) + .read(any(ByteBuffer.class)); + } + + private ConditionVariable buildReadStartedCondition() { + final ConditionVariable startedCondition = new ConditionVariable(); + doAnswer( + invocation -> { + startedCondition.open(); + return null; + }) + .when(mockUrlRequest) + .read(any(ByteBuffer.class)); + return startedCondition; + } + + private ConditionVariable buildUrlRequestStartedCondition() { + final ConditionVariable startedCondition = new ConditionVariable(); + doAnswer( + invocation -> { + startedCondition.open(); + return null; + }) + .when(mockUrlRequest) + .start(); + return startedCondition; + } + + private void assertNotCountedDown(CountDownLatch countDownLatch) throws InterruptedException { + // We are asserting that another thread does not count down the latch. We therefore sleep some + // time to give the other thread the chance to fail this test. + Thread.sleep(50); + assertThat(countDownLatch.getCount()).isGreaterThan(0L); + } + + private static byte[] buildTestDataArray(int position, int length) { + return buildTestDataBuffer(position, length).array(); + } + + public static byte[] prefixZeros(byte[] data, int requiredLength) { + byte[] prefixedData = new byte[requiredLength]; + System.arraycopy(data, 0, prefixedData, requiredLength - data.length, data.length); + return prefixedData; + } + + public static byte[] suffixZeros(byte[] data, int requiredLength) { + return Arrays.copyOf(data, requiredLength); + } + + private static ByteBuffer buildTestDataBuffer(int position, int length) { + ByteBuffer testBuffer = ByteBuffer.allocate(length); + for (int i = 0; i < length; i++) { + testBuffer.put((byte) (position + i)); + } + testBuffer.flip(); + return testBuffer; + } + + // Returns a copy of what is remaining in the src buffer from the current position to capacity. + private static byte[] copyByteBufferToArray(ByteBuffer src) { + if (src == null) { + return null; + } + byte[] copy = new byte[src.remaining()]; + int index = 0; + while (src.hasRemaining()) { + copy[index++] = src.get(); + } + return copy; + } + + private static void setSystemClockInMsAndTriggerPendingMessages(long nowMs) { + SystemClock.setCurrentTimeMillis(nowMs); + ShadowLooper.idleMainLooper(); + } + + private static NetworkException createNetworkException(int errorCode, Throwable cause) { + return new NetworkException("", cause) { + @Override + public int getErrorCode() { + return errorCode; + } + + @Override + public boolean isImmediatelyRetryable() { + return false; + } + }; + } +}