/*
* Copyright (C) 2008 Josh Guilfoyle <jasta@devtcg.org>
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*/
package org.devtcg.five.util.streaming;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.http.HttpEntity;
import org.apache.http.HttpException;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.devtcg.five.Constants;
import org.devtcg.util.CancelableThread;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Process;
import android.util.Log;
/**
* Abstraction to generically manage multiple simultaneous HTTP downloads.
* Downloads using this class must have an HTTP Content-Length specified by the
* server.
*/
public abstract class DownloadManager
{
public static final String TAG = "DownloadManager";
private final Map<String, Download> mDownloads =
Collections.synchronizedMap(new HashMap<String, Download>());
protected final ConnectivityManager mConnMan;
protected FailfastHttpClient mClient =
FailfastHttpClient.newInstance(null);
private volatile boolean mDuringShutdown = false;
/**
* Number of times we will retry after unhandled errors. Note that we
* consider the case of a failed local network handled (by a
* ConnectivityReceiver in PlaylistService), and so will not count
* as a retry attempt.
*/
private static final int NUM_RETRIES = 2;
/**
* Number of seconds to wait after each retry.
*/
private static final int RETRY_DISTANCE[] = { 15, 30, 60 };
/** Uninitialized state; caller should never see this. */
public static final int STATE_UNKNOWN = 0;
/** Connecting to remote peer. */
public static final int STATE_CONNECTING = 7;
/** Connected to peer, transfer will or has begun. */
public static final int STATE_CONNECTED = 8;
/** Download is paused due to local network failure; to be resumed
* when connectivity returns. */
public static final int STATE_PAUSED_LOCAL_FAILURE = 1;
/** Download has permanently failed due to an unexpected failure
* negotiating with server. */
public static final int STATE_HTTP_ERROR = 2;
/** Download is paused due to apparent remote network failure; to be
* retried up to {@link NUM_RETRIES} times. */
public static final int STATE_PAUSED_REMOTE_FAILURE = 3;
/** Download has permanently failed due to an unexpected failure
* writing output to disk. */
public static final int STATE_FILE_ERROR = 4;
/** Intentionally aborted. */
public static final int STATE_ABORTED = 5;
/** Download has permanently failed after too many unsuccessful retries. */
public static final int STATE_TOO_MANY_RETRIES = 6;
public DownloadManager(Context ctx)
{
mConnMan = (ConnectivityManager)ctx.getSystemService
(Context.CONNECTIVITY_SERVICE);
}
/**
* This method is provided to work around an apparent bug in HttpClient
* where aborted connections stay in the connection pool. Therefore,
* aborting downloads up to the connection pool limit will cause the
* HttpClient to refuse to execute new methods.
*/
/* package */ synchronized void refreshHttpClient()
{
if (mDuringShutdown == false && mClient != null)
{
mClient.close();
mClient = FailfastHttpClient.newInstance(null);
}
}
public void shutdown()
{
mDuringShutdown = true;
stopAllDownloads();
}
public Download lookupDownload(String url)
{
return mDownloads.get(url);
}
public Download startDownload(String url, String path, long expectedContentLength,
long resumeFrom) throws IOException
{
Download d = newDownload(url, path, expectedContentLength, resumeFrom);
mDownloads.put(url, d);
d.start();
return d;
}
public void stopDownload(String url)
{
stopDownload(mDownloads.get(url));
}
public void stopDownload(Download d)
{
if (d != null)
{
/* It's important that we synchronously join this thread as an
* abort causes the HttpClient instance we use to shutdown
* and recreate. It's important that we don't then schedule
* some new download on the soon-to-be-closed instance. */
d.requestCancelAndWait();
}
}
public void stopAllDownloads()
{
Download[] downloadsCopy;
synchronized(mDownloads) {
downloadsCopy =
mDownloads.values().toArray(new Download[mDownloads.size()]);
}
for (Download d: downloadsCopy)
stopDownload(d);
}
public void resumeDownloads()
{
synchronized(mDownloads) {
for (Download d: mDownloads.values())
{
if (d.isPaused() == true)
d.retry();
}
}
}
protected Download newDownload(String url, String path, long expectedContentLength,
long resumeFrom) throws IOException
{
return new Download(this, url, path, expectedContentLength, resumeFrom);
}
protected void removeDownload(String url)
{
mDownloads.remove(url);
}
public List<Download> getDownloadsCopy()
{
synchronized(mDownloads) {
return new ArrayList<Download>(mDownloads.values());
}
}
public boolean isNetworkAvailable()
{
NetworkInfo info = mConnMan.getActiveNetworkInfo();
if (info == null)
return false;
return info.isConnected();
}
public abstract void onProgressUpdate(String url, int percent);
public abstract void onStateChange(String url, int state, String message);
/**
* Handle fatal download failure. The default response is to delete
* the destination file.
*/
public void onError(String url, int state, String err)
{
Download dl = mDownloads.get(url);
dl.getDestination().delete();
}
/**
* Triggered after a download was aborted. Default response is
* to delete the destination file.
*/
public void onAborted(String url)
{
Download dl = mDownloads.get(url);
dl.getDestination().delete();
}
public abstract void onFinished(String url);
public static class Download extends CancelableThread
{
private static final int BUFFER_SIZE = 2048;
private static final AtomicInteger mCount = new AtomicInteger(1);
private final DownloadManager mManager;
private final String mUrl;
private final File mDest;
private final FileOutputStream mOut;
private HttpGet mMethod;
private final Object mPauseLock = new Object();
private volatile int mState = STATE_UNKNOWN;
private String mStateMsg;
/* Tracks download attempts so that we can eventually fail. */
private int mAttempts = 0;
private long mResumeFrom;
private final Object mResponseLock = new Object();
private volatile boolean mPostResponse;
private long mBytes = 0;
private long mLength = -1;
private final long mExpectedLength;
private int mLastProgress = 0;
/**
* @param expectedContentLength
* The presence of this field is a mistake. We assume that
* the server will respond with this content length (as this
* was the file size reported during the last sync), but this
* assumption is used in the tail reading stream to mean the
* actual amount of data to wait on! This means if the file
* has changed at all, even if slightly (say, meta data
* modified), the playback attempt may lock up towards the
* end. Also, this will prevent us from ever doing
* transcoding, so really we should nuke this field and find
* a better way to defer the necessity of this value until it
* has been provided by the server in response to this
* download.
*/
private Download(DownloadManager mgr, String url, String path,
long expectedContentLength, long resumeFrom) throws IOException
{
super("Download #" + mCount.getAndIncrement() + ": " + url);
mManager = mgr;
mUrl = url;
mDest = new File(path);
mExpectedLength = expectedContentLength;
mResumeFrom = resumeFrom;
mBytes = resumeFrom;
mOut = new FileOutputStream(path, (resumeFrom > 0));
}
public String getUrl()
{
return mUrl;
}
public File getDestination()
{
return mDest;
}
public int getDownloadState()
{
return mState;
}
public String getStateMessage()
{
return mStateMsg;
}
@Override
protected void onRequestCancel()
{
synchronized(this) {
mState = STATE_ABORTED;
mManager.onStateChange(mUrl, STATE_ABORTED, null);
if (mMethod != null)
mMethod.abort();
/*
* HttpClient4 that ships with Android apparently has issues
* releasing connections properly when their method is
* prematurely aborted. To work around this issue, we recreate
* the HttpClient object on abort only.
*/
mManager.refreshHttpClient();
/* We've changed the state away from paused so this should
* work just fine to break out of that loop. */
synchronized(mPauseLock) {
mPauseLock.notify();
}
}
}
public synchronized boolean isPaused()
{
if (mState == STATE_PAUSED_REMOTE_FAILURE)
return true;
if (mState == STATE_PAUSED_LOCAL_FAILURE)
return true;
return false;
}
public void retry()
{
Log.i(DownloadManager.TAG, "Forcing retry: " + mUrl);
assert isPaused() == true;
/* Break out of a timed wait... */
interrupt();
/* Break out of an indefinite wait... */
synchronized(mPauseLock) {
try {
setState(STATE_UNKNOWN);
mPauseLock.notify();
} catch (AbortedException e) {}
}
}
public long getExpectedContentLength()
{
return mExpectedLength;
}
public long getContentLength()
{
return mLength;
}
/**
* Efficiently wait on the download thread to get a
* response from the remote peer. Intended to be called from
* an external thread in order to access the response
* content length.
*
* Returns immediately if the response is already available.
*/
public void waitForResponse()
{
synchronized(mResponseLock) {
while (isAlive() == true && mPostResponse == false)
{
try {
mResponseLock.wait();
} catch (InterruptedException e) {}
}
}
}
public int getProgress()
{
return mLastProgress;
}
public synchronized void setState(int state)
throws AbortedException
{
setState(state, null);
}
public synchronized void setState(int state, String message)
throws AbortedException
{
if (mState == STATE_ABORTED)
throw new AbortedException();
mState = state;
mStateMsg = message;
mManager.onStateChange(mUrl, state, message);
}
private void tryDownload()
throws Exception
{
HttpGet method = new HttpGet(mUrl);
if (mResumeFrom > 0)
method.addHeader("Range", "bytes=" + mResumeFrom + "-");
setState(STATE_CONNECTING);
synchronized(this) {
mMethod = method;
}
InputStream in = null;
/* Differentiates failure to save to disk versus failure
* to retrieve from the network. */
boolean networkIO = true;
try {
HttpEntity ent = null;
try {
/* Synchronization is necessary as we need to
* reset the mClient instance to work around a
* connection release bug in HttpClient 4.x. */
HttpClient client;
synchronized(mManager) {
client = mManager.mClient;
}
HttpResponse resp = client.execute(mMethod);
setState(STATE_CONNECTED);
StatusLine status = resp.getStatusLine();
int statusCode = status.getStatusCode();
if (mResumeFrom == 0)
{
if (statusCode != HttpStatus.SC_OK)
throw new IOException("HTTP GET failed: " + status);
}
else
{
if (statusCode != HttpStatus.SC_PARTIAL_CONTENT)
throw new IOException("HTTP GET failed: " + status);
}
if ((ent = resp.getEntity()) == null)
throw new IOException("No entity?");
if (mResumeFrom == 0)
mLength = ent.getContentLength();
else
{
String rangeHdr =
resp.getLastHeader("Content-Range").getValue();
Matcher matcher =
Pattern.compile("bytes (\\d+)-(\\d+)/(\\d+)")
.matcher(rangeHdr);
if (matcher.matches() == false)
throw new IOException("Can't parse Content-Range");
long firstBytePos = Long.parseLong(matcher.group(1));
long lastBytePos = Long.parseLong(matcher.group(2));
long length = Long.parseLong(matcher.group(3));
if (lastBytePos + 1 != length)
throw new IOException("Range request inconsistently answered");
if (firstBytePos != mResumeFrom)
throw new IOException("Range request inconsistently answered");
mLength = length;
}
if (mLength != mExpectedLength)
{
Log.w(Constants.TAG, "Content-Length response (" + mLength +
") did not match our expectation (" + mExpectedLength + ")");
}
} finally {
synchronized(mResponseLock) {
mPostResponse = true;
mResponseLock.notify();
}
}
if (hasCanceled())
return;
in = ent.getContent();
byte[] b = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(b)) >= 0)
{
if (hasCanceled())
break;
try {
mOut.write(b, 0, n);
} catch (IOException e) {
throw new LocalIOException(e);
}
mBytes += n;
int progress = (int)
(((float)mBytes / (float)mLength) * 100f);
if (progress > mLastProgress)
{
mManager.onProgressUpdate(mUrl, progress);
mLastProgress = progress;
}
}
if (mBytes < mLength)
throw new HttpException("Server didn't send as much as it said it would.");
} catch (HttpException e) {
setState(STATE_HTTP_ERROR, e.toString());
throw e;
} catch (LocalIOException e) {
setState(STATE_FILE_ERROR, e.toString());
throw e;
} catch (IOException e) {
if (mManager.isNetworkAvailable() == false)
setState(STATE_PAUSED_LOCAL_FAILURE, e.toString());
else
setState(STATE_PAUSED_REMOTE_FAILURE, e.toString());
throw e;
} finally {
synchronized(this) {
mMethod = null;
}
if (in != null)
try { in.close(); } catch (IOException e) {}
}
}
public void run()
{
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
DOWNLOAD_RETRY_LOOP:
for (;;)
{
boolean pauseIndefinitely = false;
try {
tryDownload();
mManager.onFinished(mUrl);
break;
} catch (Exception e) {
Log.d(DownloadManager.TAG,
"Download of " + mUrl + " failed: " + e.toString());
switch (mState)
{
case STATE_PAUSED_REMOTE_FAILURE:
case STATE_HTTP_ERROR:
/* Don't count as a retry failure unless no data was
* downloaded during this attempt. */
if (mBytes > mResumeFrom)
mAttempts = 0;
else
{
if (mAttempts++ >= NUM_RETRIES)
{
mManager.onError(mUrl, STATE_TOO_MANY_RETRIES, e.toString());
break DOWNLOAD_RETRY_LOOP;
}
}
break;
case STATE_PAUSED_LOCAL_FAILURE:
mAttempts = 0;
pauseIndefinitely = true;
break;
case STATE_FILE_ERROR:
mManager.onError(mUrl, STATE_FILE_ERROR, e.toString());
break DOWNLOAD_RETRY_LOOP;
case STATE_ABORTED:
mManager.onAborted(mUrl);
break DOWNLOAD_RETRY_LOOP;
default:
throw new IllegalStateException("Unknown state " + mState);
}
mResumeFrom = mBytes;
}
if (pauseIndefinitely == true)
{
/* Wait until we are manually restarted or stopped. Will
* never expire. */
Log.i(DownloadManager.TAG,
"Waiting indefinitely to retry failed download: " + mUrl);
synchronized(mPauseLock) {
try {
while (isPaused() == true)
mPauseLock.wait();
} catch (InterruptedException e) {}
}
}
else
{
/* Wait longer after each failed attempt. */
int wait = RETRY_DISTANCE[mAttempts];
Log.i(DownloadManager.TAG, "Waiting " + wait +
" seconds to retry failed download: " + mUrl);
try {
Thread.sleep(wait * 1000);
} catch (InterruptedException e) {}
}
Log.i(DownloadManager.TAG, "Retrying download: " + mUrl);
}
try {
mOut.close();
} catch (IOException e) {
Log.e(DownloadManager.TAG, "TODO: HANDLE ME", e);
}
mManager.removeDownload(mUrl);
}
private static class AbortedException extends Exception {}
private static class LocalIOException extends Exception
{
public LocalIOException(IOException e) {
super(e);
}
}
}
}