Skip to content

Package: TileCache

TileCache

nameinstructionbranchcomplexitylinemethod
TileCache(MapView.Options)
M: 0 C: 40
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 9
100%
M: 0 C: 1
100%
dispose()
M: 6 C: 23
79%
M: 1 C: 1
50%
M: 1 C: 1
50%
M: 3 C: 6
67%
M: 0 C: 1
100%
downloadTile(Path, URI)
M: 24 C: 85
78%
M: 1 C: 2
67%
M: 1 C: 2
67%
M: 6 C: 20
77%
M: 0 C: 1
100%
getErrorBody(HttpResponse)
M: 0 C: 10
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 4
100%
M: 0 C: 1
100%
getPendingTileCount()
M: 0 C: 4
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
lambda$getErrorBody$3(String)
M: 4 C: 0
0%
M: 0 C: 0
100%
M: 1 C: 0
0%
M: 1 C: 0
0%
M: 1 C: 0
0%
lambda$getErrorBody$4(HttpResponse, String)
M: 8 C: 0
0%
M: 0 C: 0
100%
M: 1 C: 0
0%
M: 1 C: 0
0%
M: 1 C: 0
0%
lambda$loadImageFromCache$2(AbstractTile, Object)
M: 0 C: 11
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
lambda$new$0(int)
M: 0 C: 6
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
lambda$retainPendingTiles$1(int, AbstractTile)
M: 8 C: 0
0%
M: 2 C: 0
0%
M: 2 C: 0
0%
M: 1 C: 0
0%
M: 1 C: 0
0%
loadImageFromCache(AbstractTile, Path)
M: 0 C: 13
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 3
100%
M: 0 C: 1
100%
loadTileInBackground(AbstractTile)
M: 4 C: 67
94%
M: 1 C: 7
88%
M: 1 C: 4
80%
M: 1 C: 14
93%
M: 0 C: 1
100%
resolveCachedTilePath(AbstractTile)
M: 0 C: 13
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
retainPendingTiles(int)
M: 0 C: 12
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 3
100%
M: 0 C: 1
100%
static {...}
M: 0 C: 4
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
tileLoader()
M: 56 C: 9
14%
M: 7 C: 1
13%
M: 4 C: 1
20%
M: 17 C: 2
11%
M: 0 C: 1
100%

Coverage

1: /*
2: * *************************************************************************************************************************************************************
3: *
4: * MapView: a JavaFX map renderer for tile-based servers
5: * http://tidalwave.it/projects/mapview
6: *
7: * Copyright (C) 2024 - 2025 by Tidalwave s.a.s. (http://tidalwave.it)
8: *
9: * *************************************************************************************************************************************************************
10: *
11: * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
12: * You may obtain a copy of the License at
13: *
14: * http://www.apache.org/licenses/LICENSE-2.0
15: *
16: * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
17: * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
18: *
19: * *************************************************************************************************************************************************************
20: *
21: * git clone https://bitbucket.org/tidalwave/mapview-src
22: * git clone https://github.com/tidalwave-it/mapview-src
23: *
24: * *************************************************************************************************************************************************************
25: */
26: package it.tidalwave.mapviewer.impl;
27:
28: import java.lang.ref.SoftReference;
29: import jakarta.annotation.Nonnull;
30: import java.util.ArrayList;
31: import java.util.List;
32: import java.util.Map;
33: import java.util.Optional;
34: import java.util.concurrent.BlockingQueue;
35: import java.util.concurrent.ConcurrentHashMap;
36: import java.util.concurrent.ExecutorService;
37: import java.util.concurrent.LinkedBlockingQueue;
38: import java.util.concurrent.TimeUnit;
39: import java.util.stream.IntStream;
40: import java.nio.charset.StandardCharsets;
41: import java.nio.file.Files;
42: import java.nio.file.Path;
43: import java.net.URI;
44: import java.net.http.HttpClient;
45: import java.net.http.HttpRequest;
46: import java.net.http.HttpResponse;
47: import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
48: import it.tidalwave.mapviewer.javafx.MapView;
49: import lombok.extern.slf4j.Slf4j;
50: import static it.tidalwave.mapviewer.impl.NameMangler.mangle;
51: import static java.net.http.HttpClient.Redirect.ALWAYS;
52:
53: /***************************************************************************************************************************************************************
54: *
55: * A cache for tiles.
56: *
57: * @author Fabrizio Giudici
58: *
59: **************************************************************************************************************************************************************/
60: @Slf4j
61: public class TileCache
62: {
63: /** The queue of tiles to be downloaded. */
64: @Nonnull
65: /* visible for testing */ final BlockingQueue<AbstractTile> tileQueue;
66:
67: /** Options of the map view. */
68: @Nonnull
69: private final MapView.Options options;
70:
71: /** The thread pool for downloading tiles. */
72: @Nonnull
73: private final ExecutorService executorService;
74:
75: /** This is important to avoid flickering then the TileGrid recreates tiles. */
76: /* visible for testing */ final Map<URI, SoftReference<Object>> memoryImageCache = new ConcurrentHashMap<>();
77:
78: /** The unterminated runnables still in execution after {@link #dispose()} - should be empty. */
79: /* visible for testing */ final List<Runnable> unterminatedRunnables = new ArrayList<>();
80:
81: /***********************************************************************************************************************************************************
82: *
83: **********************************************************************************************************************************************************/
84: @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_BAD_PRACTICE")
85: public TileCache (@Nonnull final MapView.Options options)
86: {
87: this.options = options;
88: tileQueue = new LinkedBlockingQueue<>(options.tileQueueCapacity());
89: final var poolSize = options.poolSize();
90: executorService = options.executorService().apply(poolSize);
91: IntStream.range(0, poolSize).forEach(i -> executorService.execute(this::tileLoader));
92: }
93:
94: /***********************************************************************************************************************************************************
95: * {@return the number of tiles in the download queue}.
96: **********************************************************************************************************************************************************/
97: public int getPendingTileCount()
98: {
99: return tileQueue.size();
100: }
101:
102: /***********************************************************************************************************************************************************
103: * Loads a tile in background.
104: * @param tile the tile to download
105: **********************************************************************************************************************************************************/
106: public final void loadTileInBackground (@Nonnull final AbstractTile tile)
107: {
108: log.debug("loadTileInBackground({})", tile);
109: final var imageRef = memoryImageCache.get(tile.getUri());
110:• final var image = (imageRef == null) ? null : imageRef.get();
111:
112:• if (image != null)
113: {
114: log.debug("loading tile from memory cache...");
115: tile.setImageByBitmap(image);
116: }
117: else
118: {
119: final var localPath = resolveCachedTilePath(tile);
120: log.debug("looking in disk cache {} ...", localPath);
121:
122:• if (Files.exists(localPath))
123: {
124: loadImageFromCache(tile, localPath);
125: }
126: else
127: {
128: tile.setImageByBitmap(options.waitingImage().get());
129:
130:• if (tileQueue.offer(tile))
131: {
132: log.debug("added tile {} to download queue - tiles in queue: {}", tile.getUri(), tileQueue.size());
133: }
134: else
135: {
136: log.warn("download queue full, discarding: {}", tile);
137: }
138: }
139: }
140: }
141:
142: /***********************************************************************************************************************************************************
143: * Clears the queue of pending tiles, retaining only those for the given zoom level.
144: * @param zoom the zoom level to retain
145: **********************************************************************************************************************************************************/
146: public void retainPendingTiles (final int zoom)
147: {
148: log.debug("retainPendingTiles({})", zoom);
149:• tileQueue.removeIf(tile -> tile.getZoom() != zoom);
150: }
151:
152: /***********************************************************************************************************************************************************
153: *
154: **********************************************************************************************************************************************************/
155: public void dispose()
156: {
157: log.debug("dispose()");
158: unterminatedRunnables.addAll(executorService.shutdownNow());
159:
160: try
161: {
162:• if (!executorService.awaitTermination(10, TimeUnit.SECONDS))
163: {
164: log.warn("The following threads were not terminated: {}", unterminatedRunnables);
165: }
166: }
167: catch (InterruptedException e)
168: {
169: log.warn("Interrupted while shutting down.");
170: Thread.currentThread().interrupt();
171: }
172: }
173:
174: /***********************************************************************************************************************************************************
175: * The main loop that downloads the tiles.
176: **********************************************************************************************************************************************************/
177: private void tileLoader()
178: {
179:• while (!Thread.interrupted())
180: {
181: try
182: {
183: log.debug("waiting for next tile to load... queue size = {}", tileQueue.size());
184: final var tile = tileQueue.take();
185: final var uri = tile.getUri();
186: final var localPath = resolveCachedTilePath(tile);
187:
188:• if (!Files.exists(localPath) && options.downloadAllowed())
189: {
190: downloadTile(localPath, uri);
191: }
192:
193:• if (!Files.exists(localPath))
194: {
195: tile.setImageByPath(null);
196: }
197: else
198: {
199: loadImageFromCache(tile, localPath);
200: }
201: }
202: catch (InterruptedException ignored)
203: {
204: log.info("tileLoader interrupted");
205: Thread.currentThread().interrupt();
206: break;
207: }
208: catch (Exception e) // defensive
209: {
210: log.error("", e);
211: }
212: }
213:
214: log.info("tileLoader terminated");
215: }
216:
217: /***********************************************************************************************************************************************************
218: * Loads an image from the cache.
219: * @param tile the tile
220: * @param path the path of the cache file
221: **********************************************************************************************************************************************************/
222: private void loadImageFromCache (@Nonnull final AbstractTile tile, @Nonnull final Path path)
223: {
224: log.debug("loadImageFromCache({}, {})", tile, path);
225: tile.setImageByPath(path).ifPresent(image -> memoryImageCache.put(tile.getUri(), new SoftReference<>(image)));
226: }
227:
228: /***********************************************************************************************************************************************************
229: * {@return the path of the cached tile}.
230: * @param tile the tile
231: **********************************************************************************************************************************************************/
232: @Nonnull
233: private Path resolveCachedTilePath (@Nonnull final AbstractTile tile)
234: {
235: return options.cacheFolder().resolve(tile.getSource().getCachePrefix()).resolve(mangle(tile.getUri().toString()));
236: }
237:
238: /***********************************************************************************************************************************************************
239: * Downloads a tile and stores it.
240: * @param localPath the file to store the tile into
241: * @param uri the uri of the tile
242: **********************************************************************************************************************************************************/
243: @SuppressFBWarnings("REC_CATCH_EXCEPTION")
244: /* visible for testing */ static void downloadTile (@Nonnull final Path localPath, @Nonnull final URI uri)
245: {
246: try (final var client = HttpClient.newBuilder().followRedirects(ALWAYS).build())
247: {
248: Files.createDirectories(localPath.getParent());
249: final var request = HttpRequest.newBuilder()
250: .GET()
251: .header("User-Agent", "curl/8.7.1")
252: .header("Accept", "*/*")
253: .uri(uri)
254: .build();
255: final var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
256:
257:• switch (response.statusCode())
258: {
259: case 200:
260: final var bytes = response.body();
261: Files.write(localPath, bytes);
262: log.debug("written {} bytes to {}", bytes.length, localPath);
263: break;
264: case 503:
265: log.warn("status code 503 for {}, should re-schedule; {}", uri, response.headers().map());
266: getErrorBody(response).ifPresent(log::warn);
267: // TODO: should reschedule, but not immediately, and also count for a max number of attempts
268: // TOOD: could use a different placeholder image?
269: break;
270: default:
271: log.error("status code {} for {}; {}", response.statusCode(), uri, response.headers().map());
272: getErrorBody(response).ifPresent(log::error);
273: }
274: }
275: catch (InterruptedException e)
276: {
277: log.error("", e);
278: Thread.currentThread().interrupt();
279: }
280: catch (Exception e) // defensive
281: {
282: log.error("", e);
283: }
284: }
285:
286: /***********************************************************************************************************************************************************
287: *
288: **********************************************************************************************************************************************************/
289: @Nonnull
290: private static Optional<String> getErrorBody (@Nonnull final HttpResponse<byte[]> response)
291: {
292: return response.headers()
293: .firstValue("Content-type")
294: .filter(ct -> ct.startsWith("text/"))
295: .map(r -> new String(response.body(), StandardCharsets.UTF_8)); // TODO: charset should be get from response
296: }
297: }