package com.evsuite.abrp; import android.content.Context; import android.location.Location; import android.os.SystemClock; import android.util.Log; import org.json.JSONObject; import java.io.File; import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.Arrays; import java.util.Collection; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * The roads around the car for the Grid's map, and where the car is: the activity hands in * GPS fixes ({@link #onFix}) while "Grid with map" is shown; this keeps the 3×3 tiles round * the car loaded from the app's own cache (files/map, 30 days, at most {@value #DISK_TILES} * tiles) or from the car's server ({@code POST /v1/map/roads}, server/maps.go), one at a time * on a background thread, and drops tiles from memory once the car is two tiles away. The car * asks only its own server, naming a tile rather than its position. Without a configured * server, a fix or the roads, the Grid shows its plain floor ({@link #state}). */ final class MapRoads { /** Why the map shows nothing (or {@link #SHOWING}). */ enum State { NO_SERVER, WAITING_FOR_GPS, LOADING, UNAVAILABLE, SHOWING } /** One GPS fix: where, which way (degrees from north, NaN before the car has moved), when. */ static final class Fix { final double lat, lon; final float bearing; final long elapsedMs; Fix(double lat, double lon, float bearing, long elapsedMs) { this.lat = lat; this.lon = lon; this.bearing = bearing; this.elapsedMs = elapsedMs; } } private static final String TAG = "MapRoads"; private static final int DISK_TILES = 400; private static final long DISK_AGE_MS = 30L * 24 * 60 * 60 * 1000; private static final long RETRY_MS = 60_000L; /** A fix older than this is not drawn from. */ static final long FIX_FRESH_MS = 15_000L; private final Context context; private final File dir; private final Map tiles = new ConcurrentHashMap<>(); private final Map failedAt = new ConcurrentHashMap<>(); private final Map queued = new ConcurrentHashMap<>(); private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "map-roads"); t.setPriority(Thread.MIN_PRIORITY); return t; }); private volatile Fix fix; /** The last bearing while moving; GPS has none at a standstill. */ private volatile float bearing = Float.NaN; private volatile boolean serverMissing; /** Where the bearing was last worked out from, for a GPS that gives none. */ private Location anchor; MapRoads(Context context) { this.context = context.getApplicationContext(); dir = new File(this.context.getFilesDir(), "map"); } /** A GPS fix. Main thread; the loading happens on the worker. */ void onFix(Location location) { if (location == null || !MapGeometry.usable(location.getLatitude(), location.getLongitude())) return; if (location.hasBearing() && location.hasSpeed() && location.getSpeed() > 1.5f) { bearing = location.getBearing(); anchor = location; } else if (anchor == null || location.getElapsedRealtimeNanos() - anchor.getElapsedRealtimeNanos() > 10_000_000_000L) { anchor = location; } else if (anchor.distanceTo(location) >= 8f) { // No bearing from the GPS: the way from the last place 8 m or more behind. bearing = (anchor.bearingTo(location) + 360f) % 360f; anchor = location; } Fix f = new Fix(location.getLatitude(), location.getLongitude(), bearing, SystemClock.elapsedRealtime()); fix = f; want(MapGeometry.tileX(f.lon), MapGeometry.tileY(f.lat)); } /** The latest fix, or null when there is none or it is stale. */ Fix fix() { Fix f = fix; return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f; } Collection tiles() { return tiles.values(); } State state() { if (serverMissing) return State.NO_SERVER; Fix f = fix(); if (f == null) return State.WAITING_FOR_GPS; long here = MapGeometry.key(MapGeometry.tileX(f.lon), MapGeometry.tileY(f.lat)); if (tiles.containsKey(here)) return State.SHOWING; return failedAt.containsKey(here) ? State.UNAVAILABLE : State.LOADING; } /** Queues the 3×3 tiles round (cx, cy), the car's own first, and forgets far ones. */ private void want(int cx, int cy) { for (Long key : tiles.keySet()) { if (Math.abs(MapGeometry.keyX(key) - cx) > 2 || Math.abs(MapGeometry.keyY(key) - cy) > 2) tiles.remove(key); } int[][] order = { {0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}, {1, -1}, {1, 1}, {-1, 1}, {-1, -1} }; long now = SystemClock.elapsedRealtime(); for (int[] d : order) { int x = cx + d[0], y = cy + d[1]; if (x < 0 || y < 0 || x >= (1 << MapGeometry.ZOOM) || y >= (1 << MapGeometry.ZOOM)) continue; long key = MapGeometry.key(x, y); if (tiles.containsKey(key) || queued.containsKey(key)) continue; Long failed = failedAt.get(key); if (failed != null && now - failed < RETRY_MS) continue; queued.put(key, Boolean.TRUE); worker.execute(() -> { try { load(key, x, y); } finally { queued.remove(key); } }); } } /** From the cache when fresh enough, else from the server; a stale copy beats nothing. */ private void load(long key, int x, int y) { File file = new File(dir, MapGeometry.ZOOM + "_" + x + "_" + y + ".json"); RoadTile cached = null; try { if (file.isFile()) { cached = RoadTile.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8), x, y); if (cached == null) file.delete(); } } catch (Exception e) { Log.w(TAG, "Cached map tile unreadable: " + e.getClass().getSimpleName()); } if (cached != null) { tiles.put(key, cached); if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return; } ServerConfig config = ServerConfig.get(context); if (!config.isConfigured()) { serverMissing = true; return; } serverMissing = false; try { JSONObject body = new JSONObject().put("z", MapGeometry.ZOOM).put("x", x).put("y", y); ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/map/roads", config.token, body); RoadTile tile = r.status == 200 ? RoadTile.parse(r.body, x, y) : null; if (tile == null) { // The tile stays out of the log: it says where the car is. Log.w(TAG, "Map tile not loaded: HTTP " + r.status); if (cached == null) failedAt.put(key, SystemClock.elapsedRealtime()); return; } failedAt.remove(key); tiles.put(key, tile); save(file, r.body); } catch (Exception e) { Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName()); if (cached == null) failedAt.put(key, SystemClock.elapsedRealtime()); } } private void save(File file, String body) { try { if (!dir.isDirectory() && !dir.mkdirs()) return; File tmp = new File(dir, file.getName() + ".tmp"); try (FileOutputStream out = new FileOutputStream(tmp)) { out.write(body.getBytes(StandardCharsets.UTF_8)); } if (!tmp.renameTo(file)) tmp.delete(); File[] all = dir.listFiles((d, name) -> name.endsWith(".json")); if (all != null && all.length > DISK_TILES) { Arrays.sort(all, (a, b) -> Long.compare(a.lastModified(), b.lastModified())); for (int i = 0; i < all.length - DISK_TILES; i++) all[i].delete(); } } catch (Exception e) { Log.w(TAG, "Map tile not cached: " + e.getClass().getSimpleName()); } } }