/**
* Downloader.java
* Copyright 2008 Zach Scrivena
* zachscrivena@gmail.com
* http://zs.freeshell.org/
*
* TERMS AND CONDITIONS:
* 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 3 of the License, 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.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.freeshell.zs.common;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Download a remote resource.
*/
public class Downloader
implements Runnable
{
/** wait interval in milliseconds (100) */
private static final long WAIT_INTERVAL_MILLISECONDS = 100L;
/** buffer size in number of bytes (1024) */
private static final int BUFFER_SIZE = 1024;
/** URL of the remote resource to be downloaded */
private final URL url;
/** target object to be populated */
private final Object target;
/** length of the remote resource, in number of bytes; -1 if unknown */
private int totalLength = -1;
/** number of bytes downloaded */
private int downloadedLength = 0;
/** mutex lock for fields totalLength and downloadedLength */
private final Object lengthLock = new Object();
/** string describing the current progress */
volatile private String progressString = "Waiting to start";
/** has there been an update in the progress? */
volatile private boolean progressUpdated = false;
/** Exception object representing the error, if any */
volatile private Exception error = null;
/** has the download started? */
private boolean started = false;
/** is the downloader running? */
private boolean running = false;
/** is the download cancelled? */
private boolean cancelled = false;
/** is the download completed? */
private boolean completed = false;
/** mutex lock for fields started, running, cancelled, and completed */
private final Object stateLock = new Object();
/**
* Constructor.
* The target object should not be accessed until after calling waitUntilCompleted().
*
* @param url
* URL of the remote resource to be downloaded
* @param target
* target object to be populated (File or StringBuilder object)
*/
public Downloader(
final URL url,
final Object target)
{
if ((target instanceof File) ||
(target instanceof StringBuilder))
{
this.target = target;
}
else
{
throw new IllegalArgumentException("Target must be a File or StringBuilder object.");
}
this.url = url;
synchronized (stateLock)
{
started = false;
running = false;
}
}
/**
* Get the length of the remote resource.
*
* @return
* length of the remote resource, in number of bytes; -1 if unknown
*/
public int getLength()
{
synchronized (lengthLock)
{
return totalLength;
}
}
/**
* Get the number of bytes downloaded.
*
* @return
* number of bytes downloaded
*/
public int getDownloadedLength()
{
synchronized (lengthLock)
{
return downloadedLength;
}
}
/**
* Get a string describing the current progress.
*
* @return
* string describing the current progress
*/
public String getProgressString()
{
return progressString;
}
/**
* Get the percentage describing the current progress.
*
* @return
* percentage describing the current progress; -1 if unknown
*/
public int getProgressPercent()
{
synchronized (lengthLock)
{
if ((totalLength <= 0) ||
(downloadedLength > totalLength))
{
return -1;
}
else if (downloadedLength == totalLength)
{
return 100;
}
else
{
return (int) (100.0 * downloadedLength / totalLength);
}
}
}
/**
* Has there been an update in the progress?
* The progressUpdated flag is set to false before this method returns.
*
* @return
* true if there has been an update in the progress; false otherwise
*/
public boolean isProgressUpdated()
{
if (progressUpdated)
{
progressUpdated = false;
return true;
}
else
{
return false;
}
}
/**
* Pause the download.
*/
public void pause()
{
synchronized (stateLock)
{
running = false;
}
}
/**
* Resume the download.
*/
public void resume()
{
synchronized (stateLock)
{
if (!completed)
{
running = true;
}
}
}
/**
* Cancel the download.
*/
public void cancel()
{
synchronized (stateLock)
{
cancelled = true;
}
}
/**
* Has the download started?
*
* @return
* true if download has started; false otherwise
*/
public boolean isStarted()
{
synchronized (stateLock)
{
return started;
}
}
/**
* Is the downloader running?
*
* @return
* true if downloader is running; false otherwise
*/
public boolean isRunning()
{
synchronized (stateLock)
{
return running;
}
}
/**
* Is the download cancelled?
*
* @return
* true if downloader is cancelled; false otherwise
*/
public boolean isCancelled()
{
synchronized (stateLock)
{
return cancelled;
}
}
/**
* Is the download completed?
*
* @return
* true if download is completed; false otherwise
*/
public boolean isCompleted()
{
synchronized (stateLock)
{
return completed;
}
}
/**
* Wait until the download is completed.
* The target object should be accessed only after calling this method.
*
* @throws Exception
* if an error has occurred during download, or if the download was cancelled
*/
public void waitUntilCompleted()
throws Exception
{
while (true)
{
synchronized (stateLock)
{
if (completed)
{
if (error == null)
{
return;
}
else
{
throw error;
}
}
}
Debug.sleep(WAIT_INTERVAL_MILLISECONDS);
}
}
/**
* Start downloading the remote resource.
* The target object should not be accessed until after calling waitUntilCompleted().
*/
public void run()
{
synchronized (stateLock)
{
if (started)
{
return;
}
else
{
started = true;
running = true;
}
}
BufferedInputStream bis = null;
BufferedOutputStream bos = null;
BufferedReader br = null;
try
{
/* open connection to the URL */
checkState();
progressString = "Opening connection to remote resource";
progressUpdated = true;
final URLConnection link;
try
{
link = url.openConnection();
link.connect();
}
catch (Exception e)
{
progressString = "Failed to open connection to remote resource";
progressUpdated = true;
throw e;
}
/* get length of the remote resource */
checkState();
progressString = "Getting length of remote resource";
progressUpdated = true;
/* get size of webpage in bytes; -1 if unknown */
final int length = link.getContentLength();
synchronized (lengthLock)
{
totalLength = length;
}
progressUpdated = true;
/* open input stream to remote resource */
checkState();
progressString = "Opening input stream to remote resource";
progressUpdated = true;
try
{
final InputStream input = link.getInputStream();
if (target instanceof File)
{
bis = new BufferedInputStream(input);
}
else if (target instanceof StringBuilder)
{
final String contentType = link.getContentType().toLowerCase(Locale.ENGLISH);
/* look for charset, if specified */
String charset = null;
final Matcher m = Pattern.compile(".*charset[\\s]*=([^;]++).*").matcher(contentType);
if (m.find())
{
charset = m.group(1).trim();
}
if ((charset != null) && !charset.isEmpty())
{
try
{
br = new BufferedReader(new InputStreamReader(input, charset));
}
catch (Exception e)
{
br = null;
}
}
if (br == null)
{
br = new BufferedReader(new InputStreamReader(input));
}
}
}
catch (Exception e)
{
progressString = "Failed to open input stream to remote resource";
progressUpdated = true;
throw e;
}
/* open output stream, if necessary */
if (target instanceof File)
{
checkState();
progressString = "Opening output stream to local file";
progressUpdated = true;
try
{
/* create parent directories, if necessary */
final File f = (File) target;
final File parent = f.getParentFile();
if ((parent != null) && !parent.exists())
{
parent.mkdirs();
}
bos = new BufferedOutputStream(new FileOutputStream(f));
}
catch (Exception e)
{
progressString = "Failed to open output stream to local file";
progressUpdated = true;
throw e;
}
}
/* download remote resource iteratively */
progressString = "Downloading";
progressUpdated = true;
try
{
if (target instanceof File)
{
final byte[] byteBuffer = new byte[BUFFER_SIZE];
while (true)
{
checkState();
final int byteCount = bis.read(byteBuffer, 0, BUFFER_SIZE);
/* check for end-of-stream */
if (byteCount == -1)
{
break;
}
bos.write(byteBuffer, 0, byteCount);
synchronized (lengthLock)
{
downloadedLength += byteCount;
}
progressUpdated = true;
}
}
else if (target instanceof StringBuilder)
{
final char[] charBuffer = new char[BUFFER_SIZE];
final StringBuilder sb = (StringBuilder) target;
while (true)
{
checkState();
final int charCount = br.read(charBuffer, 0, BUFFER_SIZE);
/* check for end-of-stream */
if (charCount == -1)
{
break;
}
sb.append(charBuffer, 0, charCount);
synchronized (lengthLock)
{
downloadedLength += charCount; /* may be inaccurate because byte != char */
}
progressUpdated = true;
}
}
}
catch (Exception e)
{
progressString = "Failed to download remote resource";
progressUpdated = true;
throw e;
}
/* download completed successfully */
progressString = "Download completed";
progressUpdated = true;
}
catch (Exception e)
{
error = e;
}
finally
{
/* clean-up */
for (Closeable c : new Closeable[] {bis, br, bos})
{
if (c != null)
{
try
{
c.close();
}
catch (Exception e)
{
/* ignore */
}
}
}
synchronized (stateLock)
{
running = false;
completed = true;
}
}
}
/**
* Check if the downloader state has been modified.
* This method blocks if the download has been paused, unless it is resumed or cancelled.
* An exception is thrown if the download is cancelled.
*
* @throws java.lang.Exception
* if the download is cancelled
*/
private void checkState()
throws Exception
{
while (true)
{
synchronized (stateLock)
{
if (cancelled)
{
progressString = "Download cancelled";
progressUpdated = true;
throw new Exception("Download cancelled");
}
if (running)
{
return;
}
}
Debug.sleep(WAIT_INTERVAL_MILLISECONDS);
}
}
}