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