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