// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.data.imagery;
import static org.openstreetmap.josm.tools.I18n.tr;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URL;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.jcs.access.behavior.ICacheAccess;
import org.openstreetmap.gui.jmapviewer.FeatureAdapter;
import org.openstreetmap.gui.jmapviewer.Tile;
import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource;
import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
import org.openstreetmap.josm.data.cache.CacheEntry;
import org.openstreetmap.josm.data.cache.CacheEntryAttributes;
import org.openstreetmap.josm.data.cache.ICachedLoaderListener;
import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob;
import org.openstreetmap.josm.data.preferences.LongProperty;
import org.openstreetmap.josm.tools.HttpClient;
/**
* Class bridging TMS requests to JCS cache requests
*
* @author Wiktor Niesiobędzki
* @since 8168
*/
public class TMSCachedTileLoaderJob extends JCSCachedTileLoaderJob<String, BufferedImageCacheEntry> implements TileJob, ICachedLoaderListener {
private static final Logger LOG = FeatureAdapter.getLogger(TMSCachedTileLoaderJob.class.getCanonicalName());
private static final LongProperty MAXIMUM_EXPIRES = new LongProperty("imagery.generic.maximum_expires", TimeUnit.DAYS.toMillis(30));
private static final LongProperty MINIMUM_EXPIRES = new LongProperty("imagery.generic.minimum_expires", TimeUnit.HOURS.toMillis(1));
protected final Tile tile;
private volatile URL url;
// we need another deduplication of Tile Loader listeners, as for each submit, new TMSCachedTileLoaderJob was created
// that way, we reduce calls to tileLoadingFinished, and general CPU load due to surplus Map repaints
private static final ConcurrentMap<String, Set<TileLoaderListener>> inProgress = new ConcurrentHashMap<>();
/**
* Constructor for creating a job, to get a specific tile from cache
* @param listener Tile loader listener
* @param tile to be fetched from cache
* @param cache object
* @param connectTimeout when connecting to remote resource
* @param readTimeout when connecting to remote resource
* @param headers HTTP headers to be sent together with request
* @param downloadExecutor that will be executing the jobs
*/
public TMSCachedTileLoaderJob(TileLoaderListener listener, Tile tile,
ICacheAccess<String, BufferedImageCacheEntry> cache,
int connectTimeout, int readTimeout, Map<String, String> headers,
ThreadPoolExecutor downloadExecutor) {
super(cache, connectTimeout, readTimeout, headers, downloadExecutor);
this.tile = tile;
if (listener != null) {
String deduplicationKey = getCacheKey();
synchronized (inProgress) {
Set<TileLoaderListener> newListeners = inProgress.get(deduplicationKey);
if (newListeners == null) {
newListeners = new HashSet<>();
inProgress.put(deduplicationKey, newListeners);
}
newListeners.add(listener);
}
}
}
@Override
public String getCacheKey() {
if (tile != null) {
TileSource tileSource = tile.getTileSource();
return Optional.ofNullable(tileSource.getName()).orElse("").replace(':', '_') + ':'
+ tileSource.getTileId(tile.getZoom(), tile.getXtile(), tile.getYtile());
}
return null;
}
/*
* this doesn't needs to be synchronized, as it's not that costly to keep only one execution
* in parallel, but URL creation and Tile.getUrl() are costly and are not needed when fetching
* data from cache, that's why URL creation is postponed until it's needed
*
* We need to have static url value for TileLoaderJob, as for some TileSources we might get different
* URL's each call we made (servers switching), and URL's are used below as a key for duplicate detection
*
*/
@Override
public URL getUrl() throws IOException {
if (url == null) {
synchronized (this) {
if (url == null) {
String sUrl = tile.getUrl();
if (!"".equals(sUrl)) {
url = new URL(sUrl);
}
}
}
}
return url;
}
@Override
public boolean isObjectLoadable() {
if (cacheData != null) {
byte[] content = cacheData.getContent();
try {
return content.length > 0 || cacheData.getImage() != null || isNoTileAtZoom();
} catch (IOException e) {
LOG.log(Level.WARNING, "JCS TMS - error loading from cache for tile {0}: {1}", new Object[] {tile.getKey(), e.getMessage()});
Main.warn(e);
}
}
return false;
}
@Override
protected boolean isResponseLoadable(Map<String, List<String>> headers, int statusCode, byte[] content) {
attributes.setMetadata(tile.getTileSource().getMetadata(headers));
if (tile.getTileSource().isNoTileAtZoom(headers, statusCode, content)) {
attributes.setNoTileAtZoom(true);
return false; // do no try to load data from no-tile at zoom, cache empty object instead
}
return super.isResponseLoadable(headers, statusCode, content);
}
@Override
protected boolean cacheAsEmpty() {
return isNoTileAtZoom() || super.cacheAsEmpty();
}
@Override
public void submit(boolean force) {
tile.initLoading();
try {
super.submit(this, force);
} catch (IOException | IllegalArgumentException e) {
// if we fail to submit the job, mark tile as loaded and set error message
Main.warn(e, false);
tile.finishLoading();
tile.setError(e.getMessage());
}
}
@Override
public void loadingFinished(CacheEntry object, CacheEntryAttributes attributes, LoadResult result) {
this.attributes = attributes; // as we might get notification from other object than our selfs, pass attributes along
Set<TileLoaderListener> listeners;
synchronized (inProgress) {
listeners = inProgress.remove(getCacheKey());
}
boolean status = result.equals(LoadResult.SUCCESS);
try {
tile.finishLoading(); // whatever happened set that loading has finished
// set tile metadata
if (this.attributes != null) {
for (Entry<String, String> e: this.attributes.getMetadata().entrySet()) {
tile.putValue(e.getKey(), e.getValue());
}
}
switch(result) {
case SUCCESS:
handleNoTileAtZoom();
if (attributes != null) {
int httpStatusCode = attributes.getResponseCode();
if (httpStatusCode >= 400 && !isNoTileAtZoom()) {
if (attributes.getErrorMessage() == null) {
tile.setError(tr("HTTP error {0} when loading tiles", httpStatusCode));
} else {
tile.setError(tr("Error downloading tiles: {0}", attributes.getErrorMessage()));
}
status = false;
}
}
status &= tryLoadTileImage(object); //try to keep returned image as background
break;
case FAILURE:
tile.setError("Problem loading tile");
tryLoadTileImage(object);
break;
case CANCELED:
tile.loadingCanceled();
// do nothing
}
// always check, if there is some listener interested in fact, that tile has finished loading
if (listeners != null) { // listeners might be null, if some other thread notified already about success
for (TileLoaderListener l: listeners) {
l.tileLoadingFinished(tile, status);
}
}
} catch (IOException e) {
LOG.log(Level.WARNING, "JCS TMS - error loading object for tile {0}: {1}", new Object[] {tile.getKey(), e.getMessage()});
tile.setError(e);
tile.setLoaded(false);
if (listeners != null) { // listeners might be null, if some other thread notified already about success
for (TileLoaderListener l: listeners) {
l.tileLoadingFinished(tile, false);
}
}
}
}
/**
* For TMS use BaseURL as settings discovery, so for different paths, we will have different settings (useful for developer servers)
*
* @return base URL of TMS or server url as defined in super class
*/
@Override
protected String getServerKey() {
TileSource ts = tile.getSource();
if (ts instanceof AbstractTMSTileSource) {
return ((AbstractTMSTileSource) ts).getBaseUrl();
}
return super.getServerKey();
}
@Override
protected BufferedImageCacheEntry createCacheEntry(byte[] content) {
return new BufferedImageCacheEntry(content);
}
@Override
public void submit() {
submit(false);
}
@Override
protected CacheEntryAttributes parseHeaders(HttpClient.Response urlConn) {
CacheEntryAttributes ret = super.parseHeaders(urlConn);
// keep the expiration time between MINIMUM_EXPIRES and MAXIMUM_EXPIRES, so we will cache the tiles
// at least for some short period of time, but not too long
if (ret.getExpirationTime() < now + MINIMUM_EXPIRES.get()) {
ret.setExpirationTime(now + MINIMUM_EXPIRES.get());
}
if (ret.getExpirationTime() > now + MAXIMUM_EXPIRES.get()) {
ret.setExpirationTime(now + MAXIMUM_EXPIRES.get());
}
return ret;
}
private boolean handleNoTileAtZoom() {
if (isNoTileAtZoom()) {
LOG.log(Level.FINE, "JCS TMS - Tile valid, but no file, as no tiles at this level {0}", tile);
tile.setError("No tile at this zoom level");
tile.putValue("tile-info", "no-tile");
return true;
}
return false;
}
private boolean isNoTileAtZoom() {
if (attributes == null) {
LOG.warning("Cache attributes are null");
}
return attributes != null && attributes.isNoTileAtZoom();
}
private boolean tryLoadTileImage(CacheEntry object) throws IOException {
if (object != null) {
byte[] content = object.getContent();
if (content.length > 0) {
tile.loadImage(new ByteArrayInputStream(content));
if (tile.getImage() == null) {
tile.setError(tr("Could not load image from tile server"));
return false;
}
}
}
return true;
}
}