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 * @author Wiktor Niesiobędzki
038 *
039 * Class bridging TMS requests to JCS cache requests
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            }
131        }
132        return false;
133    }
134
135    @Override
136    protected boolean isResponseLoadable(Map<String, List<String>> headers, int statusCode, byte[] content) {
137        attributes.setMetadata(tile.getTileSource().getMetadata(headers));
138        if (tile.getTileSource().isNoTileAtZoom(headers, statusCode, content)) {
139            attributes.setNoTileAtZoom(true);
140            return false; // do no try to load data from no-tile at zoom, cache empty object instead
141        }
142        return super.isResponseLoadable(headers, statusCode, content);
143    }
144
145    @Override
146    protected boolean cacheAsEmpty() {
147        return isNoTileAtZoom() || super.cacheAsEmpty();
148    }
149
150    @Override
151    public void submit(boolean force) {
152        tile.initLoading();
153        try {
154            super.submit(this, force);
155        } catch (IOException e) {
156            // if we fail to submit the job, mark tile as loaded and set error message
157            Main.warn(e, false);
158            tile.finishLoading();
159            tile.setError(e.getMessage());
160        }
161    }
162
163    @Override
164    public void loadingFinished(CacheEntry object, CacheEntryAttributes attributes, LoadResult result) {
165        this.attributes = attributes; // as we might get notification from other object than our selfs, pass attributes along
166        Set<TileLoaderListener> listeners;
167        synchronized (inProgress) {
168            listeners = inProgress.remove(getCacheKey());
169        }
170        boolean status = result.equals(LoadResult.SUCCESS);
171
172        try {
173                tile.finishLoading(); // whatever happened set that loading has finished
174                // set tile metadata
175                if (this.attributes != null) {
176                    for (Entry<String, String> e: this.attributes.getMetadata().entrySet()) {
177                        tile.putValue(e.getKey(), e.getValue());
178                    }
179                }
180
181                switch(result) {
182                case SUCCESS:
183                    handleNoTileAtZoom();
184                    int httpStatusCode = attributes.getResponseCode();
185                    if (!isNoTileAtZoom() && httpStatusCode >= 400) {
186                        if (attributes.getErrorMessage() == null) {
187                            tile.setError(tr("HTTP error {0} when loading tiles", httpStatusCode));
188                        } else {
189                            tile.setError(tr("Error downloading tiles: {0}", attributes.getErrorMessage()));
190                        }
191                        status = false;
192                    }
193                    status &= tryLoadTileImage(object); //try to keep returned image as background
194                    break;
195                case FAILURE:
196                    tile.setError("Problem loading tile");
197                    tryLoadTileImage(object);
198                    break;
199                case CANCELED:
200                    tile.loadingCanceled();
201                    // do nothing
202                }
203
204            // always check, if there is some listener interested in fact, that tile has finished loading
205            if (listeners != null) { // listeners might be null, if some other thread notified already about success
206                for (TileLoaderListener l: listeners) {
207                    l.tileLoadingFinished(tile, status);
208                }
209            }
210        } catch (IOException e) {
211            LOG.log(Level.WARNING, "JCS TMS - error loading object for tile {0}: {1}", new Object[] {tile.getKey(), e.getMessage()});
212            tile.setError(e);
213            tile.setLoaded(false);
214            if (listeners != null) { // listeners might be null, if some other thread notified already about success
215                for (TileLoaderListener l: listeners) {
216                    l.tileLoadingFinished(tile, false);
217                }
218            }
219        }
220    }
221
222    /**
223     * For TMS use BaseURL as settings discovery, so for different paths, we will have different settings (useful for developer servers)
224     *
225     * @return base URL of TMS or server url as defined in super class
226     */
227    @Override
228    protected String getServerKey() {
229        TileSource ts = tile.getSource();
230        if (ts instanceof AbstractTMSTileSource) {
231            return ((AbstractTMSTileSource) ts).getBaseUrl();
232        }
233        return super.getServerKey();
234    }
235
236    @Override
237    protected BufferedImageCacheEntry createCacheEntry(byte[] content) {
238        return new BufferedImageCacheEntry(content);
239    }
240
241    @Override
242    public void submit() {
243        submit(false);
244    }
245
246    @Override
247    protected CacheEntryAttributes parseHeaders(HttpClient.Response urlConn) {
248        CacheEntryAttributes ret = super.parseHeaders(urlConn);
249        // keep the expiration time between MINIMUM_EXPIRES and MAXIMUM_EXPIRES, so we will cache the tiles
250        // at least for some short period of time, but not too long
251        if (ret.getExpirationTime() < now + MINIMUM_EXPIRES.get()) {
252            ret.setExpirationTime(now + MINIMUM_EXPIRES.get());
253        }
254        if (ret.getExpirationTime() > now + MAXIMUM_EXPIRES.get()) {
255            ret.setExpirationTime(now + MAXIMUM_EXPIRES.get());
256        }
257        return ret;
258    }
259
260    /**
261     * Method for getting the tile from cache only, without trying to reach remote resource
262     * @return tile or null, if nothing (useful) was found in cache
263     */
264    public Tile getCachedTile() {
265        BufferedImageCacheEntry data = get();
266        if (isObjectLoadable() && isCacheElementValid()) {
267            try {
268                // set tile metadata
269                if (this.attributes != null) {
270                    for (Entry<String, String> e: this.attributes.getMetadata().entrySet()) {
271                        tile.putValue(e.getKey(), e.getValue());
272                    }
273                }
274
275                if (data != null) {
276                    if (data.getImage() != null) {
277                        tile.setImage(data.getImage());
278                        tile.finishLoading();
279                    } else {
280                        // we had some data, but we didn't get any image. Malformed image?
281                        tile.setError(tr("Could not load image from tile server"));
282                    }
283                }
284                if (isNoTileAtZoom()) {
285                    handleNoTileAtZoom();
286                    tile.finishLoading();
287                }
288                if (attributes != null && attributes.getResponseCode() >= 400) {
289                    if (attributes.getErrorMessage() == null) {
290                        tile.setError(tr("HTTP error {0} when loading tiles", attributes.getResponseCode()));
291                    } else {
292                        tile.setError(tr("Error downloading tiles: {0}", attributes.getErrorMessage()));
293                    }
294                }
295                return tile;
296            } catch (IOException e) {
297                LOG.log(Level.WARNING, "JCS TMS - error loading object for tile {0}: {1}", new Object[] {tile.getKey(), e.getMessage()});
298                return null;
299            }
300
301        } else {
302            return tile;
303        }
304    }
305
306    private boolean handleNoTileAtZoom() {
307        if (isNoTileAtZoom()) {
308            LOG.log(Level.FINE, "JCS TMS - Tile valid, but no file, as no tiles at this level {0}", tile);
309            tile.setError("No tile at this zoom level");
310            tile.putValue("tile-info", "no-tile");
311            return true;
312        }
313        return false;
314    }
315
316    private boolean isNoTileAtZoom() {
317        if (attributes == null) {
318            LOG.warning("Cache attributes are null");
319        }
320        return attributes != null && attributes.isNoTileAtZoom();
321    }
322
323    private boolean tryLoadTileImage(CacheEntry object) throws IOException {
324        if (object != null) {
325            byte[] content = object.getContent();
326            if (content != null && content.length > 0) {
327                tile.loadImage(new ByteArrayInputStream(content));
328                if (tile.getImage() == null) {
329                    tile.setError(tr("Could not load image from tile server"));
330                    return false;
331                }
332            }
333        }
334        return true;
335    }
336}