/*******************************************************************************
* Copyright (c) MOBAC developers
*
* 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 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 mobac.program.download;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import mobac.exceptions.DownloadFailedException;
import mobac.exceptions.UnrecoverableDownloadException;
import mobac.program.interfaces.HttpMapSource;
import mobac.program.interfaces.MapSourceListener;
import mobac.program.interfaces.MapSpace;
import mobac.program.model.Settings;
import mobac.program.model.TileImageType;
import mobac.program.tilestore.TileStore;
import mobac.program.tilestore.TileStoreEntry;
import mobac.utilities.Utilities;
import mobac.utilities.stream.ThrottledInputStream;
import org.apache.log4j.Logger;
public class TileDownLoader {
public static String ACCEPT = "text/html, image/png, image/jpeg, image/gif, */*;q=0.1";
static {
Object defaultReadTimeout = System.getProperty("sun.net.client.defaultReadTimeout");
if (defaultReadTimeout == null)
System.setProperty("sun.net.client.defaultReadTimeout", "15000");
System.setProperty("http.maxConnections", "20");
}
private static Logger log = Logger.getLogger(TileDownLoader.class);
private static Settings settings = Settings.getInstance();
public static byte[] getImage(int x, int y, int zoom, HttpMapSource mapSource) throws IOException,
InterruptedException, UnrecoverableDownloadException {
MapSpace mapSpace = mapSource.getMapSpace();
int maxTileIndex = mapSpace.getMaxPixels(zoom) / mapSpace.getTileSize();
if (x > maxTileIndex)
throw new RuntimeException("Invalid tile index x=" + x + " for zoom " + zoom);
if (y > maxTileIndex)
throw new RuntimeException("Invalid tile index y=" + y + " for zoom " + zoom);
TileStore ts = TileStore.getInstance();
// Thread.sleep(2000);
// Test code for creating random download failures
// if (Math.random()>0.7) throw new
// IOException("intentionally download error");
Settings s = Settings.getInstance();
TileStoreEntry tile = null;
if (s.tileStoreEnabled) {
// Copy the file from the persistent tilestore instead of
// downloading it from internet.
tile = ts.getTile(x, y, zoom, mapSource);
boolean expired = isTileExpired(tile);
if (tile != null) {
if (expired) {
log.trace("Expired: " + mapSource.getName() + " " + tile);
} else {
log.trace("Tile of map source " + mapSource.getName() + " used from tilestore");
byte[] data = tile.getData();
notifyCachedTileUsed(data.length);
return data;
}
}
}
byte[] data = null;
if (tile == null) {
data = downloadTileAndUpdateStore(x, y, zoom, mapSource);
notifyTileDownloaded(data.length);
} else {
byte[] updatedData = updateStoredTile(tile, mapSource);
if (updatedData != null) {
data = updatedData;
notifyTileDownloaded(data.length);
} else {
data = tile.getData();
notifyCachedTileUsed(data.length);
}
}
return data;
}
private static void notifyTileDownloaded(int size) {
if (Thread.currentThread() instanceof MapSourceListener) {
((MapSourceListener) Thread.currentThread()).tileDownloaded(size);
}
}
private static void notifyCachedTileUsed(int size) {
if (Thread.currentThread() instanceof MapSourceListener) {
((MapSourceListener) Thread.currentThread()).tileLoadedFromCache(size);
}
}
/**
* Download the tile from the web server and updates the tile store if the tile could be successfully retrieved.
*
* @param x
* @param y
* @param zoom
* @param mapSource
* @return
* @throws UnrecoverableDownloadException
* @throws IOException
* @throws InterruptedException
*/
public static byte[] downloadTileAndUpdateStore(int x, int y, int zoom, HttpMapSource mapSource)
throws UnrecoverableDownloadException, IOException, InterruptedException {
return downloadTileAndUpdateStore(x, y, zoom, mapSource, Settings.getInstance().tileStoreEnabled);
}
public static byte[] downloadTile(int x, int y, int zoom, HttpMapSource mapSource)
throws UnrecoverableDownloadException, IOException, InterruptedException {
return downloadTileAndUpdateStore(x, y, zoom, mapSource, false);
}
public static byte[] downloadTileAndUpdateStore(int x, int y, int zoom, HttpMapSource mapSource,
boolean useTileStore) throws UnrecoverableDownloadException, IOException, InterruptedException {
if (zoom < 0)
throw new UnrecoverableDownloadException("Negative zoom!");
HttpURLConnection conn = mapSource.getTileUrlConnection(zoom, x, y);
if (conn == null)
throw new UnrecoverableDownloadException("Tile x=" + x + " y=" + y + " zoom=" + zoom
+ " is not a valid tile in map source " + mapSource);
log.trace("Downloading " + conn.getURL());
prepareConnection(conn);
conn.connect();
int code = conn.getResponseCode();
byte[] data = loadBodyDataInBuffer(conn);
if (code != HttpURLConnection.HTTP_OK)
throw new DownloadFailedException(conn, code);
checkContentType(conn, data);
checkContentLength(conn, data);
String eTag = conn.getHeaderField("ETag");
long timeLastModified = conn.getLastModified();
long timeExpires = conn.getExpiration();
Utilities.checkForInterruption();
TileImageType imageType = Utilities.getImageType(data);
if (imageType == null)
throw new UnrecoverableDownloadException("The returned image is of unknown format");
if (useTileStore) {
TileStore.getInstance().putTileData(data, x, y, zoom, mapSource, timeLastModified, timeExpires, eTag);
}
Utilities.checkForInterruption();
return data;
}
public static byte[] updateStoredTile(TileStoreEntry tile, HttpMapSource mapSource)
throws UnrecoverableDownloadException, IOException, InterruptedException {
final int x = tile.getX();
final int y = tile.getY();
final int zoom = tile.getZoom();
final HttpMapSource.TileUpdate tileUpdate = mapSource.getTileUpdate();
switch (tileUpdate) {
case ETag: {
boolean unchanged = hasTileETag(tile, mapSource);
if (unchanged) {
if (log.isTraceEnabled())
log.trace("Data unchanged on server (eTag): " + mapSource + " " + tile);
return null;
}
break;
}
case LastModified: {
boolean isNewer = isTileNewer(tile, mapSource);
if (!isNewer) {
if (log.isTraceEnabled())
log.trace("Data unchanged on server (LastModified): " + mapSource + " " + tile);
return null;
}
break;
}
}
HttpURLConnection conn = mapSource.getTileUrlConnection(zoom, x, y);
if (conn == null)
throw new UnrecoverableDownloadException("Tile x=" + x + " y=" + y + " zoom=" + zoom
+ " is not a valid tile in map source " + mapSource);
if (log.isTraceEnabled())
log.trace(String.format("Checking %s %s", mapSource.getName(), tile));
prepareConnection(conn);
boolean conditionalRequest = false;
switch (tileUpdate) {
case IfNoneMatch: {
if (tile.geteTag() != null) {
conn.setRequestProperty("If-None-Match", tile.geteTag());
conditionalRequest = true;
}
break;
}
case IfModifiedSince: {
if (tile.getTimeLastModified() > 0) {
conn.setIfModifiedSince(tile.getTimeLastModified());
conditionalRequest = true;
}
break;
}
}
conn.connect();
Settings s = Settings.getInstance();
int code = conn.getResponseCode();
if (conditionalRequest && code == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Data unchanged on server
if (s.tileStoreEnabled) {
tile.update(conn.getExpiration());
TileStore.getInstance().putTile(tile, mapSource);
}
if (log.isTraceEnabled())
log.trace("Data unchanged on server: " + mapSource + " " + tile);
return null;
}
byte[] data = loadBodyDataInBuffer(conn);
if (code != HttpURLConnection.HTTP_OK)
throw new DownloadFailedException(conn, code);
checkContentType(conn, data);
checkContentLength(conn, data);
String eTag = conn.getHeaderField("ETag");
long timeLastModified = conn.getLastModified();
long timeExpires = conn.getExpiration();
Utilities.checkForInterruption();
TileImageType imageType = Utilities.getImageType(data);
if (imageType == null)
throw new UnrecoverableDownloadException("The returned image is of unknown format");
if (s.tileStoreEnabled) {
TileStore.getInstance().putTileData(data, x, y, zoom, mapSource, timeLastModified, timeExpires, eTag);
}
Utilities.checkForInterruption();
return data;
}
public static boolean isTileExpired(TileStoreEntry tileStoreEntry) {
if (tileStoreEntry == null)
return true;
long expiredTime = tileStoreEntry.getTimeExpires();
if (expiredTime >= 0) {
// server had set an expiration time
long maxExpirationTime = settings.tileMaxExpirationTime + tileStoreEntry.getTimeDownloaded();
long minExpirationTime = settings.tileMinExpirationTime + tileStoreEntry.getTimeDownloaded();
expiredTime = Math.max(minExpirationTime, Math.min(maxExpirationTime, expiredTime));
} else {
// no expiration time set by server - use the default one
expiredTime = tileStoreEntry.getTimeDownloaded() + settings.tileDefaultExpirationTime;
}
return (expiredTime < System.currentTimeMillis());
}
/**
* Reads all available data from the input stream of <code>conn</code> and returns it as byte array. If no input
* data is available the method returns <code>null</code>.
*
* @param conn
* @return
* @throws IOException
*/
protected static byte[] loadBodyDataInBuffer(HttpURLConnection conn) throws IOException {
InputStream input = conn.getInputStream();
byte[] data = null;
try {
if (Thread.currentThread() instanceof MapSourceListener) {
// We only throttle atlas downloads, not downloads for the preview map
long bandwidthLimit = Settings.getInstance().getBandwidthLimit();
if (bandwidthLimit > 0) {
input = new ThrottledInputStream(input);
}
}
data = Utilities.getInputBytes(input);
} catch (IOException e) {
InputStream errorIn = conn.getErrorStream();
try {
byte[] errData = Utilities.getInputBytes(errorIn);
log.trace("Retrieved " + errData.length + " error bytes for a HTTP " + conn.getResponseCode());
} catch (Exception ee) {
log.debug("Error retrieving error stream content: " + e);
} finally {
Utilities.closeStream(errorIn);
}
throw e;
} finally {
Utilities.closeStream(input);
}
log.trace("Retrieved " + data.length + " bytes for a HTTP " + conn.getResponseCode());
if (data.length == 0)
return null;
return data;
}
/**
* Performs a <code>HEAD</code> request for retrieving the <code>LastModified</code> header value.
*/
protected static boolean isTileNewer(TileStoreEntry tile, HttpMapSource mapSource) throws IOException {
long oldLastModified = tile.getTimeLastModified();
if (oldLastModified <= 0) {
log.warn("Tile age comparison not possible: " + "tile in tilestore does not contain lastModified attribute");
return true;
}
HttpURLConnection conn = mapSource.getTileUrlConnection(tile.getZoom(), tile.getX(), tile.getY());
conn.setRequestMethod("HEAD");
conn.setRequestProperty("Accept", ACCEPT);
long newLastModified = conn.getLastModified();
if (newLastModified == 0)
return true;
return (newLastModified > oldLastModified);
}
protected static boolean hasTileETag(TileStoreEntry tile, HttpMapSource mapSource) throws IOException {
String eTag = tile.geteTag();
if (eTag == null || eTag.length() == 0) {
log.warn("ETag check not possible: " + "tile in tilestore does not contain ETag attribute");
return true;
}
HttpURLConnection conn = mapSource.getTileUrlConnection(tile.getZoom(), tile.getX(), tile.getY());
conn.setRequestMethod("HEAD");
conn.setRequestProperty("Accept", ACCEPT);
String onlineETag = conn.getHeaderField("ETag");
if (onlineETag == null || onlineETag.length() == 0)
return true;
return (onlineETag.equals(eTag));
}
protected static void prepareConnection(HttpURLConnection conn) throws ProtocolException {
conn.setRequestMethod("GET");
Settings s = Settings.getInstance();
conn.setConnectTimeout(1000 * s.httpConnectionTimeout);
conn.setReadTimeout(1000 * s.httpReadTimeout);
if (conn.getRequestProperty("User-agent") == null)
conn.setRequestProperty("User-agent", s.getUserAgent());
conn.setRequestProperty("Accept", ACCEPT);
}
protected static void checkContentType(HttpURLConnection conn, byte[] data) throws UnrecoverableDownloadException {
String contentType = conn.getContentType();
if (contentType != null) {
contentType = contentType.toLowerCase();
if (!contentType.startsWith("image/")) {
if (log.isTraceEnabled() && contentType.startsWith("text/")) {
log.trace("Content (" + contentType + "): " + new String(data));
}
throw new UnrecoverableDownloadException("Content type of the loaded image is unknown: " + contentType,
UnrecoverableDownloadException.ERROR_CODE_CONTENT_TYPE);
}
}
}
/**
* Check if the retrieved data length is equal to the header value Content-Length
*
* @param conn
* @param data
* @throws UnrecoverableDownloadException
*/
protected static void checkContentLength(HttpURLConnection conn, byte[] data) throws UnrecoverableDownloadException {
int len = conn.getContentLength();
if (len < 0)
return;
if (data.length != len)
throw new UnrecoverableDownloadException("Content length is not as declared by the server: retrived="
+ data.length + " bytes expected-content-length=" + len + " bytes");
}
}