View Javadoc
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   }