/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer.upstream;
import com.google.android.exoplayer.C;
import com.google.android.exoplayer.util.Assertions;
import com.google.android.exoplayer.util.Predicate;
import com.google.android.exoplayer.util.Util;
import android.text.TextUtils;
import android.util.Log;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* An http {@link DataSource}.
*/
public class HttpDataSource implements DataSource {
/**
* A {@link Predicate} that rejects content types often used for pay-walls.
*/
public static final Predicate<String> REJECT_PAYWALL_TYPES = new Predicate<String>() {
@Override
public boolean evaluate(String contentType) {
contentType = Util.toLowerInvariant(contentType);
return !TextUtils.isEmpty(contentType)
&& (!contentType.contains("text") || contentType.contains("text/vtt"))
&& !contentType.contains("html") && !contentType.contains("xml");
}
};
/**
* Thrown when an error is encountered when trying to read from HTTP data source.
*/
public static class HttpDataSourceException extends IOException {
/*
* The {@link DataSpec} associated with the current connection.
*/
public final DataSpec dataSpec;
public HttpDataSourceException(DataSpec dataSpec) {
super();
this.dataSpec = dataSpec;
}
public HttpDataSourceException(String message, DataSpec dataSpec) {
super(message);
this.dataSpec = dataSpec;
}
public HttpDataSourceException(IOException cause, DataSpec dataSpec) {
super(cause);
this.dataSpec = dataSpec;
}
public HttpDataSourceException(String message, IOException cause, DataSpec dataSpec) {
super(message, cause);
this.dataSpec = dataSpec;
}
}
/**
* Thrown when the content type is invalid.
*/
public static final class InvalidContentTypeException extends HttpDataSourceException {
public final String contentType;
public InvalidContentTypeException(String contentType, DataSpec dataSpec) {
super("Invalid content type: " + contentType, dataSpec);
this.contentType = contentType;
}
}
/**
* Thrown when an attempt to open a connection results in a response code not in the 2xx range.
*/
public static final class InvalidResponseCodeException extends HttpDataSourceException {
/**
* The response code that was outside of the 2xx range.
*/
public final int responseCode;
/**
* An unmodifiable map of the response header fields and values.
*/
public final Map<String, List<String>> headerFields;
public InvalidResponseCodeException(int responseCode, Map<String, List<String>> headerFields,
DataSpec dataSpec) {
super("Response code: " + responseCode, dataSpec);
this.responseCode = responseCode;
this.headerFields = headerFields;
}
}
public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 8 * 1000;
public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000;
private static final String TAG = "HttpDataSource";
private static final Pattern CONTENT_RANGE_HEADER =
Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$");
private final int connectTimeoutMillis;
private final int readTimeoutMillis;
private final String userAgent;
private final Predicate<String> contentTypePredicate;
private final HashMap<String, String> requestProperties;
private final TransferListener listener;
private DataSpec dataSpec;
private HttpURLConnection connection;
private InputStream inputStream;
private boolean opened;
private long dataLength;
private long bytesRead;
/**
* @param userAgent The User-Agent string that should be used.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is
* rejected by the predicate then a {@link InvalidContentTypeException} is thrown from
* {@link #open(DataSpec)}.
*/
public HttpDataSource(String userAgent, Predicate<String> contentTypePredicate) {
this(userAgent, contentTypePredicate, null);
}
/**
* @param userAgent The User-Agent string that should be used.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is
* rejected by the predicate then a {@link InvalidContentTypeException} is thrown from
* {@link #open(DataSpec)}.
* @param listener An optional listener.
*/
public HttpDataSource(String userAgent, Predicate<String> contentTypePredicate,
TransferListener listener) {
this(userAgent, contentTypePredicate, listener, DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS);
}
/**
* @param userAgent The User-Agent string that should be used.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is
* rejected by the predicate then a {@link InvalidContentTypeException} is thrown from
* {@link #open(DataSpec)}.
* @param listener An optional listener.
* @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is
* interpreted as an infinite timeout.
* @param readTimeoutMillis The read timeout, in milliseconds. A timeout of zero is interpreted
* as an infinite timeout.
*/
public HttpDataSource(String userAgent, Predicate<String> contentTypePredicate,
TransferListener listener, int connectTimeoutMillis, int readTimeoutMillis) {
this.userAgent = Assertions.checkNotEmpty(userAgent);
this.contentTypePredicate = contentTypePredicate;
this.listener = listener;
this.requestProperties = new HashMap<String, String>();
this.connectTimeoutMillis = connectTimeoutMillis;
this.readTimeoutMillis = readTimeoutMillis;
}
/**
* Sets the value of a request header field. The value will be used for subsequent connections
* established by the source.
*
* @param name The name of the header field.
* @param value The value of the field.
*/
public void setRequestProperty(String name, String value) {
Assertions.checkNotNull(name);
Assertions.checkNotNull(value);
synchronized (requestProperties) {
requestProperties.put(name, value);
}
}
/**
* Clears the value of a request header field. The change will apply to subsequent connections
* established by the source.
*
* @param name The name of the header field.
*/
public void clearRequestProperty(String name) {
Assertions.checkNotNull(name);
synchronized (requestProperties) {
requestProperties.remove(name);
}
}
/**
* Clears all request header fields that were set by {@link #setRequestProperty(String, String)}.
*/
public void clearAllRequestProperties() {
synchronized (requestProperties) {
requestProperties.clear();
}
}
@Override
public long open(DataSpec dataSpec) throws HttpDataSourceException {
this.dataSpec = dataSpec;
this.bytesRead = 0;
try {
connection = makeConnection(dataSpec);
} catch (IOException e) {
throw new HttpDataSourceException("Unable to connect to " + dataSpec.uri.toString(), e,
dataSpec);
}
// Check for a valid response code.
int responseCode;
try {
responseCode = connection.getResponseCode();
} catch (IOException e) {
throw new HttpDataSourceException("Unable to connect to " + dataSpec.uri.toString(), e,
dataSpec);
}
if (responseCode < 200 || responseCode > 299) {
Map<String, List<String>> headers = connection.getHeaderFields();
closeConnection();
throw new InvalidResponseCodeException(responseCode, headers, dataSpec);
}
// Check for a valid content type.
String contentType = connection.getContentType();
if (contentTypePredicate != null && !contentTypePredicate.evaluate(contentType)) {
closeConnection();
throw new InvalidContentTypeException(contentType, dataSpec);
}
long contentLength = getContentLength(connection);
dataLength = dataSpec.length == C.LENGTH_UNBOUNDED ? contentLength : dataSpec.length;
if (dataSpec.length != C.LENGTH_UNBOUNDED && contentLength != C.LENGTH_UNBOUNDED
&& contentLength != dataSpec.length) {
// The DataSpec specified a length and we resolved a length from the response headers, but
// the two lengths do not match.
closeConnection();
throw new HttpDataSourceException(
new UnexpectedLengthException(dataSpec.length, contentLength), dataSpec);
}
try {
inputStream = connection.getInputStream();
} catch (IOException e) {
closeConnection();
throw new HttpDataSourceException(e, dataSpec);
}
opened = true;
if (listener != null) {
listener.onTransferStart();
}
return dataLength;
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException {
int read = 0;
try {
read = inputStream.read(buffer, offset, readLength);
} catch (IOException e) {
throw new HttpDataSourceException(e, dataSpec);
}
if (read > 0) {
bytesRead += read;
if (listener != null) {
listener.onBytesTransferred(read);
}
} else if (dataLength != C.LENGTH_UNBOUNDED && dataLength != bytesRead) {
// Check for cases where the server closed the connection having not sent the correct amount
// of data. We can only do this if we know the length of the data we were expecting.
throw new HttpDataSourceException(new UnexpectedLengthException(dataLength, bytesRead),
dataSpec);
}
return read;
}
@Override
public void close() throws HttpDataSourceException {
try {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
throw new HttpDataSourceException(e, dataSpec);
}
inputStream = null;
}
} finally {
if (opened) {
opened = false;
if (listener != null) {
listener.onTransferEnd();
}
closeConnection();
}
}
}
private void closeConnection() {
if (connection != null) {
connection.disconnect();
connection = null;
}
}
/**
* Returns the current connection, or null if the source is not currently opened.
*
* @return The current open connection, or null.
*/
protected final HttpURLConnection getConnection() {
return connection;
}
/**
* Returns the number of bytes that have been read since the most recent call to
* {@link #open(DataSpec)}.
*
* @return The number of bytes read.
*/
protected final long bytesRead() {
return bytesRead;
}
/**
* Returns the number of bytes that are still to be read for the current {@link DataSpec}.
* <p>
* If the total length of the data being read is known, then this length minus {@code bytesRead()}
* is returned. If the total length is unknown, {@link C#LENGTH_UNBOUNDED} is returned.
*
* @return The remaining length, or {@link C#LENGTH_UNBOUNDED}.
*/
protected final long bytesRemaining() {
return dataLength == C.LENGTH_UNBOUNDED ? dataLength : dataLength - bytesRead;
}
private HttpURLConnection makeConnection(DataSpec dataSpec) throws IOException {
URL url = new URL(dataSpec.uri.toString());
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(connectTimeoutMillis);
connection.setReadTimeout(readTimeoutMillis);
connection.setDoOutput(false);
synchronized (requestProperties) {
for (HashMap.Entry<String, String> property : requestProperties.entrySet()) {
connection.setRequestProperty(property.getKey(), property.getValue());
}
}
connection.setRequestProperty("Accept-Encoding", "deflate");
connection.setRequestProperty("User-Agent", userAgent);
connection.setRequestProperty("Range", buildRangeHeader(dataSpec));
connection.connect();
return connection;
}
private String buildRangeHeader(DataSpec dataSpec) {
String rangeRequest = "bytes=" + dataSpec.position + "-";
if (dataSpec.length != C.LENGTH_UNBOUNDED) {
rangeRequest += (dataSpec.position + dataSpec.length - 1);
}
return rangeRequest;
}
private long getContentLength(HttpURLConnection connection) {
long contentLength = C.LENGTH_UNBOUNDED;
String contentLengthHeader = connection.getHeaderField("Content-Length");
if (!TextUtils.isEmpty(contentLengthHeader)) {
try {
contentLength = Long.parseLong(contentLengthHeader);
} catch (NumberFormatException e) {
Log.e(TAG, "Unexpected Content-Length [" + contentLengthHeader + "]");
}
}
String contentRangeHeader = connection.getHeaderField("Content-Range");
if (!TextUtils.isEmpty(contentRangeHeader)) {
Matcher matcher = CONTENT_RANGE_HEADER.matcher(contentRangeHeader);
if (matcher.find()) {
try {
long contentLengthFromRange =
Long.parseLong(matcher.group(2)) - Long.parseLong(matcher.group(1)) + 1;
if (contentLength < 0) {
// Some proxy servers strip the Content-Length header. Fall back to the length
// calculated here in this case.
contentLength = contentLengthFromRange;
} else if (contentLength != contentLengthFromRange) {
// If there is a discrepancy between the Content-Length and Content-Range headers,
// assume the one with the larger value is correct. We have seen cases where carrier
// change one of them to reduce the size of a request, but it is unlikely anybody would
// increase it.
Log.w(TAG, "Inconsistent headers [" + contentLengthHeader + "] [" + contentRangeHeader +
"]");
contentLength = Math.max(contentLength, contentLengthFromRange);
}
} catch (NumberFormatException e) {
Log.e(TAG, "Unexpected Content-Range [" + contentRangeHeader + "]");
}
}
}
return contentLength;
}
}