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}