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 }