001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.imagery; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.io.ByteArrayInputStream; 007import java.io.IOException; 008import java.net.URL; 009import java.util.HashSet; 010import java.util.List; 011import java.util.Map; 012import java.util.Map.Entry; 013import java.util.Set; 014import java.util.concurrent.ConcurrentHashMap; 015import java.util.concurrent.ConcurrentMap; 016import java.util.concurrent.ThreadPoolExecutor; 017import java.util.logging.Level; 018import java.util.logging.Logger; 019 020import org.apache.commons.jcs.access.behavior.ICacheAccess; 021import org.openstreetmap.gui.jmapviewer.FeatureAdapter; 022import org.openstreetmap.gui.jmapviewer.Tile; 023import org.openstreetmap.gui.jmapviewer.interfaces.TileJob; 024import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener; 025import org.openstreetmap.gui.jmapviewer.interfaces.TileSource; 026import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource; 027import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry; 028import org.openstreetmap.josm.data.cache.CacheEntry; 029import org.openstreetmap.josm.data.cache.CacheEntryAttributes; 030import org.openstreetmap.josm.data.cache.ICachedLoaderListener; 031import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob; 032import org.openstreetmap.josm.data.preferences.LongProperty; 033import org.openstreetmap.josm.tools.HttpClient; 034 035/** 036 * @author Wiktor Niesiobędzki 037 * 038 * Class bridging TMS requests to JCS cache requests 039 * @since 8168 040 */ 041public class TMSCachedTileLoaderJob extends JCSCachedTileLoaderJob<String, BufferedImageCacheEntry> implements TileJob, ICachedLoaderListener { 042 private static final Logger LOG = FeatureAdapter.getLogger(TMSCachedTileLoaderJob.class.getCanonicalName()); 043 private static final LongProperty MAXIMUM_EXPIRES = new LongProperty("imagery.generic.maximum_expires", 044 30 /*days*/ * 24 /*hours*/ * 60 /*minutes*/ * 60 /*seconds*/ *1000L /*milliseconds*/); 045 private static final LongProperty MINIMUM_EXPIRES = new LongProperty("imagery.generic.minimum_expires", 046 1 /*hour*/ * 60 /*minutes*/ * 60 /*seconds*/ *1000L /*milliseconds*/); 047 private final Tile tile; 048 private volatile URL url; 049 050 // we need another deduplication of Tile Loader listeners, as for each submit, new TMSCachedTileLoaderJob was created 051 // that way, we reduce calls to tileLoadingFinished, and general CPU load due to surplus Map repaints 052 private static final ConcurrentMap<String, Set<TileLoaderListener>> inProgress = new ConcurrentHashMap<>(); 053 054 /** 055 * Constructor for creating a job, to get a specific tile from cache 056 * @param listener Tile loader listener 057 * @param tile to be fetched from cache 058 * @param cache object 059 * @param connectTimeout when connecting to remote resource 060 * @param readTimeout when connecting to remote resource 061 * @param headers HTTP headers to be sent together with request 062 * @param downloadExecutor that will be executing the jobs 063 */ 064 public TMSCachedTileLoaderJob(TileLoaderListener listener, Tile tile, 065 ICacheAccess<String, BufferedImageCacheEntry> cache, 066 int connectTimeout, int readTimeout, Map<String, String> headers, 067 ThreadPoolExecutor downloadExecutor) { 068 super(cache, connectTimeout, readTimeout, headers, downloadExecutor); 069 this.tile = tile; 070 if (listener != null) { 071 String deduplicationKey = getCacheKey(); 072 synchronized (inProgress) { 073 Set<TileLoaderListener> newListeners = inProgress.get(deduplicationKey); 074 if (newListeners == null) { 075 newListeners = new HashSet<>(); 076 inProgress.put(deduplicationKey, newListeners); 077 } 078 newListeners.add(listener); 079 } 080 } 081 } 082 083 @Override 084 public Tile getTile() { 085 return getCachedTile(); 086 } 087 088 @Override 089 public String getCacheKey() { 090 if (tile != null) { 091 TileSource tileSource = tile.getTileSource(); 092 String tsName = tileSource.getName(); 093 if (tsName == null) { 094 tsName = ""; 095 } 096 return tsName.replace(':', '_') + ':' + tileSource.getTileId(tile.getZoom(), tile.getXtile(), tile.getYtile()); 097 } 098 return null; 099 } 100 101 /* 102 * this doesn't needs to be synchronized, as it's not that costly to keep only one execution 103 * in parallel, but URL creation and Tile.getUrl() are costly and are not needed when fetching 104 * data from cache, that's why URL creation is postponed until it's needed 105 * 106 * We need to have static url value for TileLoaderJob, as for some TileSources we might get different 107 * URL's each call we made (servers switching), and URL's are used below as a key for duplicate detection 108 * 109 */ 110 @Override 111 public URL getUrl() throws IOException { 112 if (url == null) { 113 synchronized (this) { 114 if (url == null) 115 url = new URL(tile.getUrl()); 116 } 117 } 118 return url; 119 } 120 121 @Override 122 public boolean isObjectLoadable() { 123 if (cacheData != null) { 124 byte[] content = cacheData.getContent(); 125 try { 126 return content != null || cacheData.getImage() != null || isNoTileAtZoom(); 127 } catch (IOException e) { 128 LOG.log(Level.WARNING, "JCS TMS - error loading from cache for tile {0}: {1}", new Object[] {tile.getKey(), e.getMessage()}); 129 } 130 } 131 return false; 132 } 133 134 @Override 135 protected boolean isResponseLoadable(Map<String, List<String>> headers, int statusCode, byte[] content) { 136 attributes.setMetadata(tile.getTileSource().getMetadata(headers)); 137 if (tile.getTileSource().isNoTileAtZoom(headers, statusCode, content)) { 138 attributes.setNoTileAtZoom(true); 139 return false; // do no try to load data from no-tile at zoom, cache empty object instead 140 } 141 return super.isResponseLoadable(headers, statusCode, content); 142 } 143 144 @Override 145 protected boolean cacheAsEmpty() { 146 return isNoTileAtZoom() || super.cacheAsEmpty(); 147 } 148 149 @Override 150 public void submit(boolean force) { 151 tile.initLoading(); 152 try { 153 super.submit(this, force); 154 } catch (IOException e) { 155 // if we fail to submit the job, mark tile as loaded and set error message 156 tile.finishLoading(); 157 tile.setError(e.getMessage()); 158 } 159 } 160 161 @Override 162 public void loadingFinished(CacheEntry object, CacheEntryAttributes attributes, LoadResult result) { 163 this.attributes = attributes; // as we might get notification from other object than our selfs, pass attributes along 164 Set<TileLoaderListener> listeners; 165 synchronized (inProgress) { 166 listeners = inProgress.remove(getCacheKey()); 167 } 168 boolean status = result.equals(LoadResult.SUCCESS); 169 170 try { 171 tile.finishLoading(); // whatever happened set that loading has finished 172 // set tile metadata 173 if (this.attributes != null) { 174 for (Entry<String, String> e: this.attributes.getMetadata().entrySet()) { 175 tile.putValue(e.getKey(), e.getValue()); 176 } 177 } 178 179 switch(result) { 180 case SUCCESS: 181 handleNoTileAtZoom(); 182 int httpStatusCode = attributes.getResponseCode(); 183 if (!isNoTileAtZoom() && httpStatusCode >= 400) { 184 if (attributes.getErrorMessage() == null) { 185 tile.setError(tr("HTTP error {0} when loading tiles", httpStatusCode)); 186 } else { 187 tile.setError(tr("Error downloading tiles: {0}", attributes.getErrorMessage())); 188 } 189 status = false; 190 } 191 status &= tryLoadTileImage(object); //try to keep returned image as background 192 break; 193 case FAILURE: 194 tile.setError("Problem loading tile"); 195 tryLoadTileImage(object); 196 break; 197 case CANCELED: 198 tile.loadingCanceled(); 199 // do nothing 200 } 201 202 // always check, if there is some listener interested in fact, that tile has finished loading 203 if (listeners != null) { // listeners might be null, if some other thread notified already about success 204 for (TileLoaderListener l: listeners) { 205 l.tileLoadingFinished(tile, status); 206 } 207 } 208 } catch (IOException e) { 209 LOG.log(Level.WARNING, "JCS TMS - error loading object for tile {0}: {1}", new Object[] {tile.getKey(), e.getMessage()}); 210 tile.setError(e.toString()); 211 tile.setLoaded(false); 212 if (listeners != null) { // listeners might be null, if some other thread notified already about success 213 for (TileLoaderListener l: listeners) { 214 l.tileLoadingFinished(tile, false); 215 } 216 } 217 } 218 } 219 220 /** 221 * For TMS use BaseURL as settings discovery, so for different paths, we will have different settings (useful for developer servers) 222 * 223 * @return base URL of TMS or server url as defined in super class 224 */ 225 @Override 226 protected String getServerKey() { 227 TileSource ts = tile.getSource(); 228 if (ts instanceof AbstractTMSTileSource) { 229 return ((AbstractTMSTileSource) ts).getBaseUrl(); 230 } 231 return super.getServerKey(); 232 } 233 234 @Override 235 protected BufferedImageCacheEntry createCacheEntry(byte[] content) { 236 return new BufferedImageCacheEntry(content); 237 } 238 239 @Override 240 public void submit() { 241 submit(false); 242 } 243 244 @Override 245 protected CacheEntryAttributes parseHeaders(HttpClient.Response urlConn) { 246 CacheEntryAttributes ret = super.parseHeaders(urlConn); 247 // keep the expiration time between MINIMUM_EXPIRES and MAXIMUM_EXPIRES, so we will cache the tiles 248 // at least for some short period of time, but not too long 249 if (ret.getExpirationTime() < now + MINIMUM_EXPIRES.get()) { 250 ret.setExpirationTime(now + MINIMUM_EXPIRES.get()); 251 } 252 if (ret.getExpirationTime() > now + MAXIMUM_EXPIRES.get()) { 253 ret.setExpirationTime(now + MAXIMUM_EXPIRES.get()); 254 } 255 return ret; 256 } 257 258 /** 259 * Method for getting the tile from cache only, without trying to reach remote resource 260 * @return tile or null, if nothing (useful) was found in cache 261 */ 262 public Tile getCachedTile() { 263 BufferedImageCacheEntry data = get(); 264 if (isObjectLoadable() && isCacheElementValid()) { 265 try { 266 // set tile metadata 267 if (this.attributes != null) { 268 for (Entry<String, String> e: this.attributes.getMetadata().entrySet()) { 269 tile.putValue(e.getKey(), e.getValue()); 270 } 271 } 272 273 if (data != null) { 274 if (data.getImage() != null) { 275 tile.setImage(data.getImage()); 276 tile.finishLoading(); 277 } else { 278 // we had some data, but we didn't get any image. Malformed image? 279 tile.setError(tr("Could not load image from tile server")); 280 } 281 } 282 if (isNoTileAtZoom()) { 283 handleNoTileAtZoom(); 284 tile.finishLoading(); 285 } 286 if (attributes != null && attributes.getResponseCode() >= 400) { 287 if (attributes.getErrorMessage() == null) { 288 tile.setError(tr("HTTP error {0} when loading tiles", attributes.getResponseCode())); 289 } else { 290 tile.setError(tr("Error downloading tiles: {0}", attributes.getErrorMessage())); 291 } 292 } 293 return tile; 294 } catch (IOException e) { 295 LOG.log(Level.WARNING, "JCS TMS - error loading object for tile {0}: {1}", new Object[] {tile.getKey(), e.getMessage()}); 296 return null; 297 } 298 299 } else { 300 return tile; 301 } 302 } 303 304 private boolean handleNoTileAtZoom() { 305 if (isNoTileAtZoom()) { 306 LOG.log(Level.FINE, "JCS TMS - Tile valid, but no file, as no tiles at this level {0}", tile); 307 tile.setError("No tile at this zoom level"); 308 tile.putValue("tile-info", "no-tile"); 309 return true; 310 } 311 return false; 312 } 313 314 private boolean isNoTileAtZoom() { 315 if (attributes == null) { 316 LOG.warning("Cache attributes are null"); 317 } 318 return attributes != null && attributes.isNoTileAtZoom(); 319 } 320 321 private boolean tryLoadTileImage(CacheEntry object) throws IOException { 322 if (object != null) { 323 byte[] content = object.getContent(); 324 if (content != null && content.length > 0) { 325 tile.loadImage(new ByteArrayInputStream(content)); 326 if (tile.getImage() == null) { 327 tile.setError(tr("Could not load image from tile server")); 328 return false; 329 } 330 } 331 } 332 return true; 333 } 334}