Files
mg4/app/src/main/java/com/evsuite/abrp/MapRoads.java
T
nilsandClaude Opus 5.5 65dc1ba847 App 2.39.0, server 2.7.0: Grid with map
A new Overview style: the Grid over the roads round the car from
OpenStreetMap, drawn in the floor's perspective, heading up, the bigger
roads glowing brighter, the car as a chevron.

Server: POST /v1/map/roads {z:14,x,y} fetches a tile's drivable roads
from Overpass (OVERPASS_URL; one request at a time, at least a second
apart), simplifies them to tile-local points in four classes, caches
them for 30 days and never logs the tile.

App: MapRoads keeps the 3x3 tiles round the car from its own cache or
the server; GPS only while the style is shown; bearing from the GPS or
from successive positions. Without GPS, server or roads the plain floor
says why. OSM attribution on screen and in LICENSE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 23:34:21 +01:00

199 lines
8.4 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Long, RoadTile> tiles = new ConcurrentHashMap<>();
private final Map<Long, Long> failedAt = new ConcurrentHashMap<>();
private final Map<Long, Boolean> 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<RoadTile> 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());
}
}
}