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}