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.concurrent.TimeUnit;
018import java.util.logging.Level;
019import java.util.logging.Logger;
020
021import org.apache.commons.jcs.access.behavior.ICacheAccess;
022import org.openstreetmap.gui.jmapviewer.FeatureAdapter;
023import org.openstreetmap.gui.jmapviewer.Tile;
024import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
025import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
026import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
027import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource;
028import org.openstreetmap.josm.Main;
029import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
030import org.openstreetmap.josm.data.cache.CacheEntry;
031import org.openstreetmap.josm.data.cache.CacheEntryAttributes;
032import org.openstreetmap.josm.data.cache.ICachedLoaderListener;
033import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob;
034import org.openstreetmap.josm.data.preferences.LongProperty;
035import org.openstreetmap.josm.tools.HttpClient;
036
037/**
038 * Class bridging TMS requests to JCS cache requests
039 *
040 * @author Wiktor Niesiobędzki
041 * @since 8168
042 */
043public class TMSCachedTileLoaderJob extends JCSCachedTileLoaderJob<String, BufferedImageCacheEntry> implements TileJob, ICachedLoaderListener {
044    private static final Logger LOG = FeatureAdapter.getLogger(TMSCachedTileLoaderJob.class.getCanonicalName());
045    private static final LongProperty MAXIMUM_EXPIRES = new LongProperty("imagery.generic.maximum_expires", TimeUnit.DAYS.toMillis(30));
046    private static final LongProperty MINIMUM_EXPIRES = new LongProperty("imagery.generic.minimum_expires", TimeUnit.HOURS.toMillis(1));
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.length > 0 || 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                Main.warn(e);
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                Main.warn(e);
299                return null;
300            }
301
302        } else {
303            return tile;
304        }
305    }
306
307    private boolean handleNoTileAtZoom() {
308        if (isNoTileAtZoom()) {
309            LOG.log(Level.FINE, "JCS TMS - Tile valid, but no file, as no tiles at this level {0}", tile);
310            tile.setError("No tile at this zoom level");
311            tile.putValue("tile-info", "no-tile");
312            return true;
313        }
314        return false;
315    }
316
317    private boolean isNoTileAtZoom() {
318        if (attributes == null) {
319            LOG.warning("Cache attributes are null");
320        }
321        return attributes != null && attributes.isNoTileAtZoom();
322    }
323
324    private boolean tryLoadTileImage(CacheEntry object) throws IOException {
325        if (object != null) {
326            byte[] content = object.getContent();
327            if (content.length > 0) {
328                tile.loadImage(new ByteArrayInputStream(content));
329                if (tile.getImage() == null) {
330                    tile.setError(tr("Could not load image from tile server"));
331                    return false;
332                }
333            }
334        }
335        return true;
336    }
337}