001// License: GPL. For details, see Readme.txt file.
002package org.openstreetmap.gui.jmapviewer;
003
004import static org.openstreetmap.gui.jmapviewer.FeatureAdapter.tr;
005
006import java.io.IOException;
007import java.io.InputStream;
008import java.net.HttpURLConnection;
009import java.net.URL;
010import java.net.URLConnection;
011import java.util.HashMap;
012import java.util.Map;
013import java.util.Map.Entry;
014import java.util.concurrent.Executors;
015import java.util.concurrent.ThreadPoolExecutor;
016import java.util.logging.Level;
017import java.util.logging.Logger;
018
019import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
020import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
021import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
022
023/**
024 * A {@link TileLoader} implementation that loads tiles from OSM.
025 *
026 * @author Jan Peter Stotz
027 */
028public class OsmTileLoader implements TileLoader {
029
030    private static final Logger LOG = FeatureAdapter.getLogger(OsmTileLoader.class);
031
032    /** Setting key for number of threads */
033    public static final String THREADS_SETTING = "jmapviewer.osm-tile-loader.threads";
034    private static final int DEFAULT_THREADS_NUMBER = 8;
035    private static int nThreads = DEFAULT_THREADS_NUMBER;
036    static {
037        try {
038            nThreads = FeatureAdapter.getIntSetting(THREADS_SETTING, DEFAULT_THREADS_NUMBER);
039        } catch (Exception e) {
040            LOG.log(Level.SEVERE, e.getMessage(), e);
041        }
042    }
043
044    private static final ThreadPoolExecutor jobDispatcher = (ThreadPoolExecutor) Executors.newFixedThreadPool(nThreads);
045
046    private final class OsmTileJob implements TileJob {
047        private final Tile tile;
048        private InputStream input;
049        private boolean force;
050
051        private OsmTileJob(Tile tile) {
052            this.tile = tile;
053        }
054
055        @Override
056        public void run() {
057            synchronized (tile) {
058                if ((tile.isLoaded() && !tile.hasError()) || tile.isLoading())
059                    return;
060                tile.loaded = false;
061                tile.error = false;
062                tile.loading = true;
063            }
064            try {
065                URLConnection conn = loadTileFromOsm(tile);
066                if (force) {
067                    conn.setUseCaches(false);
068                }
069                loadTileMetadata(tile, conn);
070                if ("no-tile".equals(tile.getValue("tile-info"))) {
071                    tile.setError(tr("No tiles at this zoom level"));
072                } else {
073                    input = conn.getInputStream();
074                    try {
075                        tile.loadImage(input);
076                    } finally {
077                        input.close();
078                        input = null;
079                    }
080                }
081                tile.setLoaded(true);
082                listener.tileLoadingFinished(tile, true);
083            } catch (IOException e) {
084                tile.setError(e.getMessage());
085                listener.tileLoadingFinished(tile, false);
086                if (input == null) {
087                    try {
088                        LOG.log(Level.SEVERE, "Failed loading " + tile.getUrl() +": "
089                                +e.getClass() + ": " + e.getMessage());
090                    } catch (IOException ioe) {
091                        LOG.log(Level.SEVERE, ioe.getMessage(), ioe);
092                    }
093                }
094            } finally {
095                tile.loading = false;
096                tile.setLoaded(true);
097            }
098        }
099
100        @Override
101        public void submit() {
102            submit(false);
103        }
104
105        @Override
106        public void submit(boolean force) {
107            this.force = force;
108            jobDispatcher.execute(this);
109        }
110    }
111
112    /**
113     * Holds the HTTP headers. Insert e.g. User-Agent here when default should not be used.
114     */
115    public Map<String, String> headers = new HashMap<>();
116
117    public int timeoutConnect;
118    public int timeoutRead;
119
120    protected TileLoaderListener listener;
121
122    /**
123     * Constructs a new {@code OsmTileLoader}.
124     * @param listener tile loader listener
125     */
126    public OsmTileLoader(TileLoaderListener listener) {
127        this(listener, null);
128    }
129
130    public OsmTileLoader(TileLoaderListener listener, Map<String, String> headers) {
131        this.headers.put("Accept", "text/html, image/png, image/jpeg, image/gif, */*");
132        this.headers.put("User-Agent", "JMapViewer Java/"+System.getProperty("java.version"));
133        if (headers != null) {
134            this.headers.putAll(headers);
135        }
136        this.listener = listener;
137    }
138
139    @Override
140    public TileJob createTileLoaderJob(final Tile tile) {
141        return new OsmTileJob(tile);
142    }
143
144    protected URLConnection loadTileFromOsm(Tile tile) throws IOException {
145        URL url;
146        url = new URL(tile.getUrl());
147        URLConnection urlConn = url.openConnection();
148        if (urlConn instanceof HttpURLConnection) {
149            prepareHttpUrlConnection((HttpURLConnection) urlConn);
150        }
151        return urlConn;
152    }
153
154    protected void loadTileMetadata(Tile tile, URLConnection urlConn) {
155        String str = urlConn.getHeaderField("X-VE-TILEMETA-CaptureDatesRange");
156        if (str != null) {
157            tile.putValue("capture-date", str);
158        }
159        str = urlConn.getHeaderField("X-VE-Tile-Info");
160        if (str != null) {
161            tile.putValue("tile-info", str);
162        }
163
164        Long lng = urlConn.getExpiration();
165        if (lng.equals(0L)) {
166            try {
167                str = urlConn.getHeaderField("Cache-Control");
168                if (str != null) {
169                    for (String token: str.split(",")) {
170                        if (token.startsWith("max-age=")) {
171                            lng = Long.parseLong(token.substring(8)) * 1000 +
172                                    System.currentTimeMillis();
173                        }
174                    }
175                }
176            } catch (NumberFormatException e) {
177                // ignore malformed Cache-Control headers
178                if (JMapViewer.debug) {
179                    System.err.println(e.getMessage());
180                }
181            }
182        }
183        if (!lng.equals(0L)) {
184            tile.putValue("expires", lng.toString());
185        }
186    }
187
188    protected void prepareHttpUrlConnection(HttpURLConnection urlConn) {
189        for (Entry<String, String> e : headers.entrySet()) {
190            urlConn.setRequestProperty(e.getKey(), e.getValue());
191        }
192        if (timeoutConnect != 0)
193            urlConn.setConnectTimeout(timeoutConnect);
194        if (timeoutRead != 0)
195            urlConn.setReadTimeout(timeoutRead);
196    }
197
198    @Override
199    public String toString() {
200        return getClass().getSimpleName();
201    }
202
203    @Override
204    public boolean hasOutstandingTasks() {
205        return jobDispatcher.getTaskCount() > jobDispatcher.getCompletedTaskCount();
206    }
207
208    @Override
209    public void cancelOutstandingTasks() {
210        jobDispatcher.getQueue().clear();
211    }
212
213    /**
214     * Sets the maximum number of concurrent connections the tile loader will do
215     * @param num number of concurrent connections
216     */
217    public static void setConcurrentConnections(int num) {
218        jobDispatcher.setMaximumPoolSize(num);
219    }
220}