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;
+ }
+ };
+ }
+}