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