package org.fdroid.fdroid.net;
import com.nostra13.universalimageloader.core.download.BaseImageDownloader;
import org.apache.commons.io.FileUtils;
import org.fdroid.fdroid.BuildConfig;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Utils;
import org.spongycastle.util.encoders.Base64;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import info.guardianproject.netcipher.NetCipher;
public class HttpDownloader extends Downloader {
private static final String TAG = "HttpDownloader";
private static final String HEADER_IF_NONE_MATCH = "If-None-Match";
private static final String HEADER_FIELD_ETAG = "ETag";
private final String username;
private final String password;
private HttpURLConnection connection;
private int statusCode = -1;
HttpDownloader(URL url, File destFile)
throws FileNotFoundException, MalformedURLException {
this(url, destFile, null, null);
}
/**
* Create a downloader that can authenticate via HTTP Basic Auth using the supplied
* {@code username} and {@code password}.
*
* @param url The file to download
* @param destFile Where the download is saved
* @param username Username for HTTP Basic Auth, use {@code null} to ignore
* @param password Password for HTTP Basic Auth, use {@code null} to ignore
* @throws FileNotFoundException
* @throws MalformedURLException
*/
HttpDownloader(URL url, File destFile, String username, String password)
throws FileNotFoundException, MalformedURLException {
super(url, destFile);
this.username = username;
this.password = password;
}
/**
* Note: Doesn't follow redirects (as far as I'm aware).
* {@link BaseImageDownloader#getStreamFromNetwork(String, Object)} has an implementation worth
* checking out that follows redirects up to a certain point. I guess though the correct way
* is probably to check for a loop (keep a list of all URLs redirected to and if you hit the
* same one twice, bail with an exception).
*
* @throws IOException
*/
@Override
protected InputStream getDownloadersInputStream() throws IOException {
setupConnection(false);
return new BufferedInputStream(connection.getInputStream());
}
/**
* Get a remote file, checking the HTTP response code. If 'etag' is not
* {@code null}, it's passed to the server as an If-None-Match header, in
* which case expect a 304 response if nothing changed. In the event of a
* 200 response ONLY, 'retag' (which should be passed empty) may contain
* an etag value for the response, or it may be left empty if none was
* available.
*/
@Override
public void download() throws IOException, InterruptedException {
boolean resumable = false;
long fileLength = outputFile.length();
// get the file size from the server
HttpURLConnection tmpConn = getConnection();
tmpConn.setRequestMethod("HEAD");
int contentLength = -1;
if (tmpConn.getResponseCode() == 200) {
contentLength = tmpConn.getContentLength();
}
tmpConn.disconnect();
if (fileLength > contentLength) {
FileUtils.deleteQuietly(outputFile);
} else if (fileLength == contentLength && outputFile.isFile()) {
return; // already have it!
} else if (fileLength > 0) {
resumable = true;
}
setupConnection(resumable);
doDownload(resumable);
}
private boolean isSwapUrl() {
String host = sourceUrl.getHost();
return sourceUrl.getPort() > 1023 // only root can use <= 1023, so never a swap repo
&& host.matches("[0-9.]+") // host must be an IP address
&& FDroidApp.subnetInfo.isInRange(host); // on the same subnet as we are
}
private HttpURLConnection getConnection() throws IOException {
HttpURLConnection connection;
if (isSwapUrl()) {
// swap never works with a proxy, its unrouted IP on the same subnet
connection = (HttpURLConnection) sourceUrl.openConnection();
} else {
connection = NetCipher.getHttpURLConnection(sourceUrl);
}
connection.setRequestProperty("User-Agent", "F-Droid " + BuildConfig.VERSION_NAME);
if (username != null && password != null) {
// add authorization header from username / password if set
String authString = username + ":" + password;
connection.setRequestProperty("Authorization", "Basic " + Base64.toBase64String(authString.getBytes()));
}
return connection;
}
/**
* @return Whether the connection is resumable or not
*/
private void setupConnection(boolean resumable) throws IOException {
if (connection != null) {
return;
}
connection = getConnection();
if (resumable) {
// partial file exists, resume the download
connection.setRequestProperty("Range", "bytes=" + outputFile.length() + "-");
}
}
private void doDownload(boolean resumable) throws IOException, InterruptedException {
if (wantToCheckCache()) {
setupCacheCheck();
Utils.debugLog(TAG, "Checking cached status of " + sourceUrl);
statusCode = connection.getResponseCode();
}
if (isCached()) {
Utils.debugLog(TAG, sourceUrl + " is cached, so not downloading (HTTP " + statusCode + ")");
} else {
Utils.debugLog(TAG, "Need to download " + sourceUrl + " (is resumable: " + resumable + ")");
downloadFromStream(8192, resumable);
updateCacheCheck();
}
}
@Override
public boolean isCached() {
return wantToCheckCache() && statusCode == 304;
}
private void setupCacheCheck() {
if (cacheTag != null) {
connection.setRequestProperty(HEADER_IF_NONE_MATCH, cacheTag);
}
}
private void updateCacheCheck() {
cacheTag = connection.getHeaderField(HEADER_FIELD_ETAG);
}
// Testing in the emulator for me, showed that figuring out the
// filesize took about 1 to 1.5 seconds.
// To put this in context, downloading a repo of:
// - 400k takes ~6 seconds
// - 5k takes ~3 seconds
// on my connection. I think the 1/1.5 seconds is worth it,
// because as the repo grows, the tradeoff will
// become more worth it.
@Override
public int totalDownloadSize() {
return connection.getContentLength();
}
@Override
public boolean hasChanged() {
return this.statusCode != 304;
}
@Override
public void close() {
if (connection != null) {
connection.disconnect();
}
}
}