diff --git a/CodenameOne/src/com/codename1/maps/MapView.java b/CodenameOne/src/com/codename1/maps/MapView.java index 43085686173..318b9a42967 100644 --- a/CodenameOne/src/com/codename1/maps/MapView.java +++ b/CodenameOne/src/com/codename1/maps/MapView.java @@ -113,7 +113,7 @@ public void run() { /// screen it must cover proportionally more physical pixels (the standard /// slippy-map convention: mdpi = 1, hdpi = 1.5, xhdpi = 2, xxhdpi = 3, ...) /// otherwise the viewport spans many tiles and the map shows too much area. - private static double devicePixelRatio() { + static double devicePixelRatio() { switch (Display.getInstance().getDeviceDensity()) { case Display.DENSITY_HIGH: return 1.5; diff --git a/CodenameOne/src/com/codename1/maps/NativeMap.java b/CodenameOne/src/com/codename1/maps/NativeMap.java index 4cd3b534abb..00ab0ac691a 100644 --- a/CodenameOne/src/com/codename1/maps/NativeMap.java +++ b/CodenameOne/src/com/codename1/maps/NativeMap.java @@ -26,6 +26,8 @@ import com.codename1.maps.spi.MapProviderRegistry; import com.codename1.maps.vector.MapStyle; import com.codename1.maps.vector.TileSource; +import com.codename1.maps.vector.WebMercator; +import com.codename1.util.MathUtil; import com.codename1.ui.CN; import com.codename1.ui.Component; import com.codename1.ui.Container; @@ -335,9 +337,77 @@ public void fitBounds(MapBounds bounds, int paddingPixels) { fallback.fitBounds(bounds, paddingPixels); return; } - // Native providers center on the bounds; precise fit is provider work. - provider.setCamera(mapId, bounds.getCenter().getLatitude(), - bounds.getCenter().getLongitude(), provider.getZoom(mapId), 0, 0); + if (bounds == null) { + return; + } + double zoom = zoomToFit(bounds, getWidth(), getHeight(), paddingPixels, + MapView.devicePixelRatio()); + if (Double.isNaN(zoom)) { + // Nothing to fit to yet (no layout, or a single point): keep the + // zoom and just recenter. + zoom = provider.getZoom(mapId); + } else { + zoom = Math.max(provider.getMinZoom(mapId), + Math.min(provider.getMaxZoom(mapId), zoom)); + } + // Center on the projected midpoint, not the arithmetic one: Mercator + // stretches away from the equator, so a tall band centered on the mean + // of its latitudes hangs off the top of the viewport the fit just + // sized for it. + provider.setCamera(mapId, + WebMercator.centerLatitude(bounds.getSouthWest().getLatitude(), + bounds.getNorthEast().getLatitude()), + bounds.getCenter().getLongitude(), (float) zoom, 0, 0); + } + + /// The zoom at which `bounds` fills a `viewWidth` x `viewHeight` viewport + /// of device pixels, inset by `paddingPixels` on every edge, or + /// `Double.NaN` when there is nothing to fit to -- no layout yet, or bounds + /// with no extent. + /// + /// `fitBounds` used to keep the current zoom and merely recenter, so a + /// region larger than the viewport stayed clipped and + /// [MapSurface#fitBounds] did not mean the same thing on a native map as + /// on a vector one. This solves for the zoom the way the vector engine + /// does, in the same Web Mercator units, so both surfaces frame a region + /// identically. Package visible so the arithmetic can be tested without a + /// native provider. + static double zoomToFit(MapBounds bounds, int viewWidth, int viewHeight, + int paddingPixels, double pixelRatio) { + if (bounds == null || viewWidth <= 0 || viewHeight <= 0 || pixelRatio <= 0) { + return Double.NaN; + } + if (viewWidth - 2 * paddingPixels <= 0 || viewHeight - 2 * paddingPixels <= 0) { + // Padding has swallowed the viewport: there is no room left to fit + // anything into, and pretending it is one pixel wide would solve + // for an absurd zoom. + return Double.NaN; + } + // Native map SDKs use the slippy convention, where a zoom level spans + // 256 logical points; the component's size is in device pixels, so it + // converts through the same density ratio the vector engine uses. + double usableWidth = (viewWidth - 2 * paddingPixels) / pixelRatio; + double usableHeight = (viewHeight - 2 * paddingPixels) / pixelRatio; + double worldWidth = Math.abs( + WebMercator.lonToWorldX(bounds.getNorthEast().getLongitude(), 0) + - WebMercator.lonToWorldX(bounds.getSouthWest().getLongitude(), 0)); + // Clamped, or a bound touching a pole projects to infinity and the + // solved zoom collapses. + double worldHeight = Math.abs( + WebMercator.latToWorldY( + WebMercator.clampLatitude(bounds.getNorthEast().getLatitude()), 0) + - WebMercator.latToWorldY( + WebMercator.clampLatitude(bounds.getSouthWest().getLatitude()), 0)); + if (worldWidth <= 0 && worldHeight <= 0) { + return Double.NaN; + } + double horizontal = worldWidth <= 0 ? Double.MAX_VALUE : log2(usableWidth / worldWidth); + double vertical = worldHeight <= 0 ? Double.MAX_VALUE : log2(usableHeight / worldHeight); + return Math.min(horizontal, vertical); + } + + private static double log2(double v) { + return MathUtil.log(v) / MathUtil.log(2); } // ---- MapSurface: map objects ----------------------------------------- diff --git a/CodenameOne/src/com/codename1/maps/Polyline.java b/CodenameOne/src/com/codename1/maps/Polyline.java index 2f52610a544..5dde36d0fd6 100644 --- a/CodenameOne/src/com/codename1/maps/Polyline.java +++ b/CodenameOne/src/com/codename1/maps/Polyline.java @@ -27,6 +27,12 @@ /// A connected sequence of line segments drawn on a map. Add one through /// [MapSurface#addPolyline(Polyline)]. +/// +/// A polyline joins the vertices you give it with *straight* segments; it +/// knows nothing about roads. To draw a route that follows the road network, +/// ask a routing service for the road geometry and draw that -- see +/// [com.codename1.maps.routing.Routing], or decode a geometry you fetched +/// yourself with [#fromEncoded(String)]. public final class Polyline extends MapObject { private final List points; @@ -50,6 +56,31 @@ public Polyline(LatLng[] pts) { } } + /// Creates a polyline from an *encoded polyline* geometry at + /// [PolylineCodec]'s default precision -- the shape virtually every + /// directions API returns for a route. + public static Polyline fromEncoded(String encoded) { + return through(PolylineCodec.decode(encoded)); + } + + /// Creates a polyline from an *encoded polyline* geometry at an explicit + /// decimal precision (5 for the classic format, 6 for `polyline6`). + /// + /// Throws `IllegalArgumentException` when `precision` falls outside 1 to + /// 10; a precision that cannot scale coordinates sensibly would otherwise + /// decode into a line drawn in the wrong place. See [PolylineCodec]. + public static Polyline fromEncoded(String encoded, int precision) { + return through(PolylineCodec.decode(encoded, precision)); + } + + /// Wraps already-decoded vertices, so neither factory has to restate the + /// codec's default precision. + private static Polyline through(List decoded) { + Polyline pl = new Polyline(); + pl.points.addAll(decoded); + return pl; + } + /// Appends a vertex. public Polyline addPoint(LatLng point) { points.add(point); diff --git a/CodenameOne/src/com/codename1/maps/PolylineCodec.java b/CodenameOne/src/com/codename1/maps/PolylineCodec.java new file mode 100644 index 00000000000..8ce9008ff02 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/PolylineCodec.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import java.util.ArrayList; +import java.util.List; + +/// Reads and writes the *encoded polyline* format -- the compact ASCII +/// representation of a coordinate sequence that virtually every directions +/// and routing service uses for route geometry (Google Directions, OSRM, +/// Mapbox Directions, GraphHopper, Valhalla, OpenRouteService). +/// +/// Use it to turn a route geometry straight into something drawable: +/// +/// ```java +/// map.addPolyline(Polyline.fromEncoded(geometryFromMyDirectionsApi)); +/// ``` +/// +/// The encoding stores each coordinate as a zig-zag, base-64-ish delta from +/// the previous one at a fixed decimal `precision`. Precision 5 is the +/// original Google format and the OSRM/Google/GraphHopper default; precision +/// 6 (`polyline6`) is what Valhalla and OSRM's `geometries=polyline6` emit. +/// Decoding with the wrong precision yields coordinates off by a factor of +/// ten, so pass the precision your service documents. +public final class PolylineCodec { + + private static final int DEFAULT_PRECISION = 5; + /// The smallest decimal precision that still scales coordinates at all. + private static final int MIN_PRECISION = 1; + /// Comfortably past the precision 5/6/7 that real services emit, while + /// keeping the scale well inside the range a `double` holds exactly, so + /// no accepted precision can round a coordinate wrongly. + private static final int MAX_PRECISION = 10; + private static final int CHUNK_BITS = 5; + private static final int CHUNK_MASK = 0x1f; + private static final int CONTINUATION_BIT = 0x20; + /// Largest legal chunk: five data bits plus the continuation bit. + private static final int MAX_CHUNK = 0x3f; + private static final int ASCII_OFFSET = 63; + + private PolylineCodec() { + } + + /// Decodes an encoded polyline at the default precision of 5. + /// + /// #### Parameters + /// + /// - `encoded`: the encoded geometry, may be `null` + /// + /// #### Returns + /// + /// the decoded [LatLng] vertices, empty when `encoded` is `null` or blank + public static List decode(String encoded) { + return decode(encoded, DEFAULT_PRECISION); + } + + /// Decodes an encoded polyline at an explicit decimal precision (5 for the + /// classic format, 6 for `polyline6`). + /// + /// Trailing bytes that do not form a complete coordinate pair -- the usual + /// symptom of a truncated response -- are dropped rather than throwing, so + /// a partial geometry still draws the coordinates it did carry. A value cut + /// in half is discarded too: half a delta is not a coordinate, and emitting + /// it would put a spurious vertex on the map and skew the route bounds. + /// Decoding likewise stops at the first character outside the encoding's + /// alphabet instead of folding it into a coordinate. + /// + /// #### Parameters + /// + /// - `encoded`: the encoded geometry, may be `null` + /// + /// - `precision`: the number of decimal digits the encoder scaled by, + /// between 1 and 10 + /// + /// #### Returns + /// + /// the decoded [LatLng] vertices, never `null` + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when `precision` is outside 1 to 10, which + /// would scale every coordinate into nonsense rather than fail + public static List decode(String encoded, int precision) { + double factor = factor(precision); + List points = new ArrayList(); + if (encoded == null || encoded.length() == 0) { + return points; + } + int[] cursor = new int[1]; + long[] value = new long[1]; + long lat = 0; + long lng = 0; + int len = encoded.length(); + while (cursor[0] < len) { + if (!readValue(encoded, cursor, value)) { + break; + } + long dLat = value[0]; + if (!readValue(encoded, cursor, value)) { + // The longitude is missing or was cut in half: emitting the + // partial value would place a bogus vertex on the map. + break; + } + lat += dLat; + lng += value[0]; + points.add(new LatLng(lat / factor, lng / factor)); + } + return points; + } + + /// Encodes [LatLng] vertices at the default precision of 5. + /// + /// #### Parameters + /// + /// - `points`: the vertices to encode, may be `null` + /// + /// #### Returns + /// + /// the encoded geometry, empty for a `null` or empty list + public static String encode(List points) { + return encode(points, DEFAULT_PRECISION); + } + + /// Encodes [LatLng] vertices at an explicit decimal precision. + /// + /// #### Parameters + /// + /// - `points`: the vertices to encode, may be `null` + /// + /// - `precision`: the number of decimal digits to scale by, between 1 and 10 + /// + /// #### Returns + /// + /// the encoded geometry, never `null` + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when `precision` is outside 1 to 10 + public static String encode(List points, int precision) { + double factor = factor(precision); + StringBuilder sb = new StringBuilder(); + if (points == null) { + return sb.toString(); + } + long prevLat = 0; + long prevLng = 0; + for (Object pointObj : points) { + LatLng p = (LatLng) pointObj; + long lat = Math.round(p.getLatitude() * factor); + long lng = Math.round(p.getLongitude() * factor); + writeValue(sb, lat - prevLat); + writeValue(sb, lng - prevLng); + prevLat = lat; + prevLng = lng; + } + return sb.toString(); + } + + /// The scale a `precision` of decimal digits multiplies by. + /// + /// Out-of-range values are rejected rather than quietly producing garbage: + /// a precision of 0 or less leaves the scale at 1 and inflates every + /// coordinate by five orders of magnitude, and a very large one overflows + /// the scale to infinity and collapses every coordinate to zero. Both + /// decode without complaint into a map full of wrong places. + private static double factor(int precision) { + if (precision < MIN_PRECISION || precision > MAX_PRECISION) { + throw new IllegalArgumentException("precision must be between " + MIN_PRECISION + + " and " + MAX_PRECISION + ", was " + precision); + } + double f = 1; + for (int i = 0; i < precision; i++) { + f *= 10; + } + return f; + } + + /// Reads one zig-zag encoded delta into `out[0]`, advancing `cursor[0]` + /// past it. + /// + /// Returns false when the value did not end cleanly -- the string ran out + /// before its terminating chunk, or a character fell outside the encoding's + /// alphabet. The accumulated bits are a fraction of the real delta in that + /// case, so the caller must discard them rather than treat them as a + /// coordinate. + private static boolean readValue(String encoded, int[] cursor, long[] out) { + int len = encoded.length(); + long result = 0; + int shift = 0; + boolean terminated = false; + while (cursor[0] < len) { + int b = encoded.charAt(cursor[0]++) - ASCII_OFFSET; + if (b < 0 || b > MAX_CHUNK) { + // Outside the '?'..'~' alphabet the encoding uses. A character + // below the offset goes negative, and negative reads as a + // terminating chunk, so without this check junk in the middle + // of a geometry would end the value early and emit garbage. + out[0] = 0; + return false; + } + result |= ((long) (b & CHUNK_MASK)) << shift; + shift += CHUNK_BITS; + if (b < CONTINUATION_BIT) { + terminated = true; + break; + } + if (shift >= 64) { + break; + } + } + out[0] = (result & 1) != 0 ? ~(result >>> 1) : result >>> 1; + return terminated; + } + + private static void writeValue(StringBuilder sb, long value) { + long v = value < 0 ? ~(value << 1) : value << 1; + while (v >= CONTINUATION_BIT) { + sb.append((char) ((CONTINUATION_BIT | (int) (v & CHUNK_MASK)) + ASCII_OFFSET)); + v >>= CHUNK_BITS; + } + sb.append((char) (v + ASCII_OFFSET)); + } +} diff --git a/CodenameOne/src/com/codename1/maps/routing/OsrmRouteService.java b/CodenameOne/src/com/codename1/maps/routing/OsrmRouteService.java new file mode 100644 index 00000000000..229af08463e --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/OsrmRouteService.java @@ -0,0 +1,730 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkEvent; +import com.codename1.io.NetworkManager; +import com.codename1.io.Util; +import com.codename1.maps.LatLng; +import com.codename1.maps.PolylineCodec; +import com.codename1.ui.CN; +import com.codename1.ui.events.ActionListener; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// The keyless [RouteService] Codename One uses by default: routing over the +/// OpenStreetMap road network through the +/// [OSRM](https://project-osrm.org) HTTP API. +/// +/// Like the OpenFreeMap basemap behind [com.codename1.maps.MapView], it needs +/// no API key and no signup, so a road-following route works out of the box in +/// the simulator and on device. +/// +/// **Before you ship**: the default endpoint is OSRM's *public demo server*. +/// It is provided for development and demos, has no SLA, is rate limited and +/// may refuse long routes. For production point this service at your own OSRM +/// instance (or any OSRM-compatible endpoint) and nothing else in your code +/// changes: +/// +/// ```java +/// Routing.setService(new OsrmRouteService("https://osrm.example.com")); +/// ``` +/// +/// **On travel modes**: the request URL carries the mode as OSRM's profile +/// path component, which OSRM-compatible hosted services (Mapbox Directions +/// and friends) use to pick a profile. A stock `osrm-routed` process does +/// not: it serves the single dataset it was prepared and started with and +/// ignores that part of the path. So one `OsrmRouteService` speaks to one +/// endpoint, and that endpoint answers with whatever profile it holds -- +/// the public demo server holds the car profile, which is why +/// [TravelMode#WALKING] and [TravelMode#CYCLING] are accepted there but +/// answered with driving data. To offer several modes off self-hosted OSRM, +/// give each mode its own endpoint and pick the matching service: +/// +/// ```java +/// OsrmRouteService walking = new OsrmRouteService("https://osrm-foot.example.com"); +/// walking.findRoutes(request.setTravelMode(TravelMode.WALKING), callback); +/// ``` +public class OsrmRouteService implements RouteService { + + /// The OSRM public demo server, used when no other base URL is configured. + /// Suitable for development and demos only -- see the class documentation. + public static final String DEMO_BASE_URL = "https://router.project-osrm.org"; + + private String baseUrl = DEMO_BASE_URL; + + /// Creates a service pointing at the OSRM public demo server. + public OsrmRouteService() { + } + + /// Creates a service pointing at an OSRM-compatible endpoint, for example + /// `https://osrm.example.com`. + public OsrmRouteService(String baseUrl) { + setBaseUrl(baseUrl); + } + + /// The endpoint routes are requested from, without a trailing slash. + public String getBaseUrl() { + return baseUrl; + } + + /// Points this service at an OSRM-compatible endpoint. A `null` or empty + /// value restores [#DEMO_BASE_URL]; a trailing slash is trimmed. + public OsrmRouteService setBaseUrl(String baseUrl) { + if (baseUrl == null || baseUrl.length() == 0) { + this.baseUrl = DEMO_BASE_URL; + return this; + } + while (baseUrl.length() > 1 && baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + this.baseUrl = baseUrl; + return this; + } + + /// {@inheritDoc} + @Override + public String getId() { + return "osrm"; + } + + /// {@inheritDoc} + /// + /// Always true -- OSRM needs no credentials. + @Override + public boolean isAvailable() { + return true; + } + + /// {@inheritDoc} + @Override + public void findRoutes(RouteRequest request, final RouteCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback is required: routing is asynchronous, " + + "so there is no other way to report the result"); + } + if (request == null || request.getOrigin() == null || request.getDestination() == null) { + fail(callback, "A route request needs both an origin and a destination", null); + return; + } + String unusable = firstUnroutable(request); + if (unusable != null) { + fail(callback, unusable, null); + return; + } + RouteConnection req = new RouteConnection(callback); + req.setUrl(buildUrl(request)); + req.setPost(false); + req.start(); + } + + /// Names the first coordinate in `request` that cannot be routed from, or + /// `null` when they are all usable. + /// + /// A `NaN` or infinite coordinate has to be caught before the URL is + /// built. [#appendFixed] rounds, and `Math.round(NaN)` is 0, so a `NaN` + /// would be written as `0.000000` and quietly route from null island + /// instead of failing. [LatLng] does not stop them either: its range + /// clamps compare against `NaN`, and every such comparison is false. + private static String firstUnroutable(RouteRequest request) { + if (!isUsable(request.getOrigin())) { + return "The route origin is not a usable coordinate"; + } + for (Object waypoint : request.getWaypoints()) { + if (!isUsable((LatLng) waypoint)) { + return "A route waypoint is not a usable coordinate"; + } + } + if (!isUsable(request.getDestination())) { + return "The route destination is not a usable coordinate"; + } + return null; + } + + private static boolean isUsable(LatLng coord) { + return isReal(coord.getLatitude()) && isReal(coord.getLongitude()); + } + + private static boolean isReal(double value) { + return !Double.isNaN(value) && !Double.isInfinite(value); + } + + /// Builds the OSRM request URL for `request`. Package visible so the shape + /// of the query can be asserted without hitting the network. + String buildUrl(RouteRequest request) { + StringBuilder sb = new StringBuilder(baseUrl); + sb.append("/route/v1/").append(request.getTravelMode().getId()).append('/'); + appendCoordinate(sb, request.getOrigin()); + for (Object waypoint : request.getWaypoints()) { + sb.append(';'); + appendCoordinate(sb, (LatLng) waypoint); + } + sb.append(';'); + appendCoordinate(sb, request.getDestination()); + sb.append("?overview=full&geometries=polyline"); + sb.append("&steps=").append(request.isSteps() ? "true" : "false"); + sb.append("&alternatives=").append(request.isAlternatives() ? "true" : "false"); + return sb.toString(); + } + + /// Parses an OSRM `route` service response into [Route] objects, best + /// first. + /// + /// Exposed so an app that fetches from an OSRM-compatible endpoint through + /// its own transport (a proxy, a cached response, a bundled fixture) can + /// reuse the same parsing. + /// + /// **Geometry precision**: this reads geometries as `geometries=polyline`, + /// the precision-5 encoding this service always requests. A response + /// fetched with `geometries=polyline6` decodes ten times off through here; + /// decode those yourself with + /// [com.codename1.maps.PolylineCodec#decode(String, int)] at precision 6. + /// + /// #### Parameters + /// + /// - `json`: the raw response body + /// + /// #### Returns + /// + /// the routes found, never empty + /// + /// #### Throws + /// + /// - `IOException`: when the body is malformed -- unparseable, or holding + /// a route, leg or step that is not a JSON object -- or when OSRM + /// reported that it could not route the request. Malformed input never + /// escapes as an unchecked exception, so catching this is enough. + public static List parseResponse(String json) throws IOException { + if (json == null || json.length() == 0) { + throw new IOException("Empty routing response"); + } + Map root; + try { + root = JSONParser.parseJSON(json); + } catch (RuntimeException e) { + // The parser is lenient with most junk, but not contractually so. + // Converting keeps the promise that IOException is all a caller + // has to catch. + throw new IOException("Malformed routing response: " + + describe(e, "it could not be parsed as JSON"), e); + } + String code = string(root.get("code")); + if (code.length() > 0 && !"Ok".equals(code)) { + throw new IOException(describeErrorCode(code, string(root.get("message")))); + } + Object routesObj = root.get("routes"); + if (!(routesObj instanceof List) || ((List) routesObj).isEmpty()) { + throw new IOException("The routing service returned no route"); + } + List routes = new ArrayList(); + for (Object routeObj : (List) routesObj) { + routes.add(parseRoute(requireObject(routeObj, "route"))); + } + return routes; + } + + /// Returns `value` when it is a list, `null` when the field was absent, + /// and throws when it is present as something else. + /// + /// Absent is legitimate -- a request with `steps=false` carries no steps. + /// Present-but-not-a-list is malformed, and quietly reading it as absent + /// would hand back a half-populated route instead of reporting the bad + /// response [#parseResponse(String)] promises to report. + private static List requireListOrNull(Object value, String what) throws IOException { + if (value == null) { + return null; + } + if (!(value instanceof List)) { + throw new IOException("Malformed routing response: " + what + " is not a list"); + } + return (List) value; + } + + /// Casts a decoded JSON value that has to be an object, turning the + /// malformed case into the [IOException] [#parseResponse(String)] + /// documents rather than letting an unchecked cast escape a public API. + private static Map requireObject(Object value, String what) throws IOException { + if (!(value instanceof Map)) { + throw new IOException("Malformed routing response: " + what + " is not an object"); + } + return (Map) value; + } + + private static Route parseRoute(Map json) throws IOException { + List points = PolylineCodec.decode(string(json.get("geometry"))); + if (points.isEmpty()) { + // The request always asks for overview=full, so a route with no + // decodable geometry is a malformed answer rather than a short + // one. Accepting it would draw an empty polyline and report + // success, which is worse than saying the response was bad. + throw new IOException("Malformed routing response: a route carried no geometry"); + } + List legs = new ArrayList(); + String summary = ""; + Object legsObj = requireListOrNull(json.get("legs"), "route legs"); + if (legsObj != null) { + for (Object legObj : (List) legsObj) { + RouteLeg leg = parseLeg(requireObject(legObj, "route leg")); + legs.add(leg); + if (summary.length() == 0) { + summary = leg.getSummary(); + } + } + } + return new Route(points, legs, number(json.get("distance")), + number(json.get("duration")), summary); + } + + private static RouteLeg parseLeg(Map json) throws IOException { + List steps = new ArrayList(); + Object stepsObj = requireListOrNull(json.get("steps"), "route steps"); + if (stepsObj != null) { + for (Object stepObj : (List) stepsObj) { + steps.add(parseStep(requireObject(stepObj, "route step"))); + } + } + return new RouteLeg(string(json.get("summary")), number(json.get("distance")), + number(json.get("duration")), steps); + } + + private static RouteStep parseStep(Map json) { + String roadName = string(json.get("name")); + String type = ""; + String modifier = ""; + int exit = 0; + LatLng start = null; + Object maneuverObj = json.get("maneuver"); + if (maneuverObj instanceof Map) { + Map maneuver = (Map) maneuverObj; + type = string(maneuver.get("type")); + modifier = string(maneuver.get("modifier")); + exit = (int) number(maneuver.get("exit")); + start = parseLocation(maneuver.get("location")); + } + return new RouteStep(describeManeuver(type, modifier, roadName, exit), roadName, + number(json.get("distance")), number(json.get("duration")), start, + PolylineCodec.decode(string(json.get("geometry")))); + } + + /// OSRM encodes locations as a `[longitude, latitude]` pair -- note the + /// order, which is the reverse of [LatLng]. + private static LatLng parseLocation(Object location) { + if (!(location instanceof List) || ((List) location).size() < 2) { + return null; + } + List pair = (List) location; + return new LatLng(number(pair.get(1)), number(pair.get(0))); + } + + /// Turns an OSRM maneuver into a readable instruction. OSRM itself returns + /// only the structured maneuver, leaving the phrasing to the client. + private static String describeManeuver(String type, String modifier, String roadName, + int exit) { + String onto = roadName.length() > 0 ? " onto " + roadName : ""; + if ("depart".equals(type)) { + return roadName.length() > 0 ? "Head out on " + roadName : "Start"; + } + if ("arrive".equals(type)) { + return "Arrive at your destination"; + } + if ("turn".equals(type) || "end of road".equals(type) + || "roundabout turn".equals(type)) { + // "roundabout turn" is OSRM's small-roundabout case, taken as an + // ordinary turn. Falling through to the generic wording gave + // "Roundabout turn onto Main Street" and threw the direction away. + return turnPhrase(modifier) + onto; + } + if ("exit roundabout".equals(type) || "exit rotary".equals(type)) { + return "Exit the roundabout" + onto; + } + if ("continue".equals(type) || "new name".equals(type)) { + return "Continue" + onto; + } + if ("merge".equals(type)) { + return "Merge" + onto; + } + if ("on ramp".equals(type)) { + return "Take the ramp" + onto; + } + if ("off ramp".equals(type)) { + return "Take the exit" + onto; + } + if ("fork".equals(type)) { + return "Keep " + sideOf(modifier) + onto; + } + if ("roundabout".equals(type) || "rotary".equals(type)) { + // OSRM numbers the exit; without it the instruction is useless at + // every roundabout with more than one way out. + if (exit > 0) { + return "At the roundabout take the " + ordinal(exit) + " exit" + onto; + } + return "Enter the roundabout and exit" + onto; + } + if (type.length() == 0) { + return "Continue" + onto; + } + return capitalize(type) + onto; + } + + /// English ordinal for a roundabout exit: 1st, 2nd, 3rd, 4th ... The + /// teens are the exception that catches naive implementations (11th, not + /// 11st), though a roundabout that large is hypothetical. + private static String ordinal(int n) { + int lastTwo = n % 100; + if (lastTwo >= 11 && lastTwo <= 13) { + return n + "th"; + } + switch (n % 10) { + case 1: + return n + "st"; + case 2: + return n + "nd"; + case 3: + return n + "rd"; + default: + return n + "th"; + } + } + + private static String turnPhrase(String modifier) { + if ("uturn".equals(modifier)) { + return "Make a U-turn"; + } + if ("straight".equals(modifier) || modifier.length() == 0) { + return "Continue straight"; + } + return "Turn " + modifier; + } + + private static String sideOf(String modifier) { + if (modifier.indexOf("right") >= 0) { + return "right"; + } + if (modifier.indexOf("left") >= 0) { + return "left"; + } + return "going"; + } + + private static String capitalize(String s) { + if (s.length() == 0) { + return s; + } + return s.substring(0, 1).toUpperCase() + s.substring(1); + } + + private static String describeErrorCode(String code, String message) { + if (message.length() > 0) { + return message; + } + if ("NoRoute".equals(code)) { + return "No route connects those points for the selected travel mode"; + } + if ("NoSegment".equals(code)) { + return "One of the points is too far from any road"; + } + if ("TooBig".equals(code)) { + return "The routing request is too large for the service"; + } + return "The routing service reported: " + code; + } + + /// The readable half of an exception, for the `message` a [RouteCallback] + /// may put in front of a user. Falls back to `fallback` when the exception + /// carries no message of its own, so a bare `UnknownHostException` does + /// not surface as an empty string or as a raw class name. + private static String describe(Throwable error, String fallback) { + String message = error == null ? null : error.getMessage(); + return message == null || message.length() == 0 ? fallback : message; + } + + private static String string(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static double number(Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + if (value == null) { + return 0; + } + try { + return Double.parseDouble(String.valueOf(value)); + } catch (NumberFormatException err) { + // A non-numeric distance/duration is treated as unknown. + return 0; + } + } + + /// OSRM takes `longitude,latitude` in the path. Formatted by hand to six + /// decimals (about 10cm) so no locale or scientific notation can leak into + /// the URL. + private static void appendCoordinate(StringBuilder sb, LatLng coord) { + appendFixed(sb, coord.getLongitude()); + sb.append(','); + appendFixed(sb, coord.getLatitude()); + } + + private static void appendFixed(StringBuilder sb, double value) { + long scaled = Math.round(Math.abs(value) * 1000000.0); + if (value < 0 && scaled != 0) { + sb.append('-'); + } + sb.append(scaled / 1000000L).append('.'); + String fraction = Long.toString(scaled % 1000000L); + for (int i = fraction.length(); i < 6; i++) { + sb.append('0'); + } + sb.append(fraction); + } + + private static void fail(final RouteCallback callback, final String message, + final Throwable error) { + CN.callSerially(new Runnable() { + @Override + public void run() { + callback.routeFailed(message, error); + } + }); + } + + /// The network request, kept as a named class so the single-delivery + /// guarantee of [RouteService#findRoutes] is enforced in one place. + /// + /// The request is deliberately *not* marked `failSilently`: + /// [com.codename1.io.NetworkManager] swallows transport exceptions + /// outright for silent requests, so [#handleException] would never run and + /// the caller would wait forever for a callback that never came. Since + /// every failure hook here is overridden and none of them delegate to + /// `super`, nothing reaches the framework's default retry dialog either + /// way. + /// + /// That still leaves one path where nothing would be delivered: an + /// app-wide error listener registered through + /// [com.codename1.io.NetworkManager#addErrorListener] that *consumes* the + /// event makes `NetworkManager` skip this request's hooks entirely. A + /// completion listener closes that hole -- see [#actionPerformed]. + /// + /// The success and HTTP-error paths cannot be suppressed at all: + /// `postResponse` and `handleErrorResponseCode` are invoked on the request + /// directly, with no listener in between. One residual case survives, and + /// needs an app to consume *two* different global event streams: a + /// consuming error listener hides the transport exception, and a progress + /// listener registered before this one that also consumes + /// `PROGRESS_TYPE_COMPLETED` starves the backstop, because + /// [com.codename1.ui.util.EventDispatcher] stops dispatching at a consumed + /// event. Consuming a progress event has no defined meaning in the + /// framework, so this is documented rather than worked around -- the only + /// unsuppressable hook left is a getter the network manager happens to + /// call, and depending on that side effect would be far more fragile than + /// the case it guards against. + private static final class RouteConnection extends ConnectionRequest + implements ActionListener { + + private final RouteCallback callback; + private boolean delivered; + private List routes; + private String errorMessage; + private Throwable error; + /// Attempts queued and not yet completed. Starts at one for the + /// initial queueing and rises with every [#retry()], so a redirect + /// hop is not mistaken for the end of the request. + private int outstandingAttempts = 1; + + RouteConnection(RouteCallback callback) { + this.callback = callback; + } + + /// Queues the request, watching for its completion so the callback is + /// delivered even when nothing else reports the outcome. + void start() { + NetworkManager nm = NetworkManager.getInstance(); + nm.addProgressListener(this); + try { + nm.addToQueue(this); + } catch (RuntimeException e) { + // Queueing validates synchronously -- a base URL that is not + // HTTP fails right here. Nothing will ever run or complete, so + // detach the listener (it would otherwise pin this request and + // its callback to the NetworkManager for good) and report the + // failure the same way as any other, rather than letting it + // escape a call documented to answer through the callback. + detach(); + failLater("The routing request could not be sent: " + + describe(e, "the endpoint was rejected"), e); + } + } + + /// Counts the extra attempt before delegating. `retry()` runs inside + /// `performOperationComplete`, ahead of the completion event for the + /// attempt that scheduled it, so the count is always raised before the + /// event it has to outlive. + @Override + public void retry() { + synchronized (this) { + outstandingAttempts++; + } + super.retry(); + } + + /// The completion backstop. + /// + /// `NetworkManager` fires this from its `finally` block for every + /// attempt -- including a redirect hop that is about to be retried -- + /// so it cannot be read as "the request is over" on its own. Counting + /// attempts against [#retry()] can: only when the last one has + /// completed is there nothing left to wait for. Testing `isRedirecting()` + /// instead would be a race, because this runs on the EDT after the + /// network thread may already have started the next attempt and reset + /// the flag. + /// + /// A real result always wins: `postResponse` is queued onto the EDT + /// before this event, and this hops once more before delivering. + @Override + public void actionPerformed(NetworkEvent n) { + // Identity, not equality: the listener is registered globally, so + // it sees every request's progress and must pick out this exact + // instance. + if (n.getConnectionRequest() != this) { //NOPMD CompareObjectsWithEquals + return; + } + if (n.getProgressType() != NetworkEvent.PROGRESS_TYPE_COMPLETED) { + return; + } + boolean finished; + synchronized (this) { + outstandingAttempts--; + finished = outstandingAttempts <= 0; + } + if (!finished) { + return; + } + detach(); + // failLater claims first and returns immediately when the delivery + // is already spoken for, so the common case -- a real result + // already landed or is queued -- costs nothing here. + failLater("The routing request ended without a result", null); + } + + @Override + protected void readResponse(InputStream input) throws IOException { + byte[] data = Util.readInputStream(input); + try { + routes = parseResponse(new String(data, "UTF-8")); + } catch (Exception e) { + // Deliberately not Throwable: an OutOfMemoryError or + // StackOverflowError is a VM problem, not a routing failure, + // and disguising it as one only makes it harder to find. + routes = null; + error = e; + errorMessage = describe(e, "The routing response could not be read"); + } + } + + @Override + protected void postResponse() { + if (routes != null) { + deliverRoutes(); + } else { + deliverFailure(errorMessage, error); + } + } + + @Override + protected void handleException(Exception err) { + failLater("The routing request failed: " + + describe(err, "the network could not be reached"), err); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + failLater("The routing service returned HTTP " + code + + (message == null || message.length() == 0 ? "" : " (" + message + ")"), null); + } + + /// Stops listening for progress. Called from every delivery path, not + /// just the backstop: an app-level progress listener registered before + /// this one can *consume* the completion event, and + /// [com.codename1.ui.util.EventDispatcher] then never reaches this + /// listener at all. Detaching only from [#actionPerformed] would leave + /// one listener -- holding this request and the application's callback + /// -- attached to the [NetworkManager] for every route ever requested. + private void detach() { + NetworkManager.getInstance().removeProgressListener(this); + } + + /// Takes ownership of the single delivery, returning true for exactly + /// one caller. Claiming is separate from delivering so an outcome that + /// still has to hop to the EDT can stake its claim *now*: otherwise the + /// backstop, which also runs on the EDT, could slip its generic + /// "ended without a result" in ahead of the real reason while that hop + /// was still queued. + private synchronized boolean claim() { + if (delivered) { + return false; + } + delivered = true; + return true; + } + + private void deliverRoutes() { + if (!claim()) { + return; + } + detach(); + callback.routesFound(routes); + } + + private void deliverFailure(String message, Throwable err) { + if (!claim()) { + return; + } + detach(); + callback.routeFailed(message, err); + } + + /// Both network failure hooks run on the network thread, so the + /// callback contract (always the EDT) is honored by hopping over. + /// The claim is taken before the hop, not inside it, so nothing can + /// deliver a different outcome while this one is still queued. + private void failLater(final String message, final Throwable err) { + if (!claim()) { + return; + } + detach(); + CN.callSerially(new Runnable() { + @Override + public void run() { + callback.routeFailed(message, err); + } + }); + } + } +} diff --git a/CodenameOne/src/com/codename1/maps/routing/Route.java b/CodenameOne/src/com/codename1/maps/routing/Route.java new file mode 100644 index 00000000000..fbb7463fcfd --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/Route.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapBounds; +import com.codename1.maps.Polyline; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A road-following journey returned by a [RouteService]: the geometry that +/// traces the actual roads, how long it is, how long it takes, and the legs +/// and turn-by-turn steps that make it up. +/// +/// Drawing it is one call -- unlike handing raw endpoints to a +/// [Polyline], which would just join them with a straight line: +/// +/// ```java +/// map.addPolyline(route.toPolyline()); +/// map.fitBounds(route.getBounds(), 40); +/// ``` +public final class Route { + + private final List points; + private final List legs; + private final double distanceMeters; + private final double durationSeconds; + private final String summary; + private MapBounds bounds; + + /// Creates a route. Called by [RouteService] implementations. + /// + /// #### Parameters + /// + /// - `points`: the [LatLng] road geometry, defensively copied + /// + /// - `legs`: the [RouteLeg]s between consecutive stops, defensively copied + /// + /// - `distanceMeters`: the total length in meters + /// + /// - `durationSeconds`: the total estimated travel time in seconds + /// + /// - `summary`: a short human readable description of the route + public Route(List points, List legs, double distanceMeters, double durationSeconds, + String summary) { + this.points = points == null ? new ArrayList() : new ArrayList(points); + this.legs = legs == null ? new ArrayList() : new ArrayList(legs); + this.distanceMeters = distanceMeters; + this.durationSeconds = durationSeconds; + this.summary = summary == null ? "" : summary; + } + + /// The unmodifiable road geometry as [LatLng] vertices, dense enough to + /// trace the shape of the roads travelled. + public List getPoints() { + return Collections.unmodifiableList(points); + } + + /// The unmodifiable [RouteLeg]s, one per pair of consecutive stops. + public List getLegs() { + return Collections.unmodifiableList(legs); + } + + /// The total length of the route in meters. + public double getDistanceMeters() { + return distanceMeters; + } + + /// The total estimated travel time in seconds. + public double getDurationSeconds() { + return durationSeconds; + } + + /// A short human readable description, typically the major roads used. + public String getSummary() { + return summary; + } + + /// The smallest [MapBounds] containing the whole route, or `null` when the + /// route has no geometry. Pass it to + /// [com.codename1.maps.MapSurface#fitBounds] to frame the journey. + /// + /// **Antimeridian caveat**: [MapBounds] is an axis-aligned box whose + /// longitudes always run west to east, so it cannot describe a span that + /// wraps past 180 degrees. A route crossing the antimeridian -- Fiji to + /// Samoa, say -- therefore reports a box that runs the long way round the + /// globe, and framing it zooms out to most of the world instead of the + /// Pacific. This is a limitation of the bounds type rather than of + /// routing, and it affects anything built from + /// [MapBounds#fromCoordinates]; frame such a route from its own points + /// instead of from these bounds. + public MapBounds getBounds() { + if (bounds == null) { + bounds = MapBounds.fromCoordinates(points); + } + return bounds; + } + + /// Builds a [Polyline] tracing this route, ready to hand to + /// [com.codename1.maps.MapSurface#addPolyline]. Each call returns a fresh + /// polyline, so styling one does not affect another. + public Polyline toPolyline() { + Polyline pl = new Polyline(); + for (Object point : points) { + pl.addPoint((LatLng) point); + } + return pl; + } + + /// {@inheritDoc} + @Override + public String toString() { + return "Route{" + distanceMeters + "m, " + durationSeconds + "s, " + + points.size() + " point(s)}"; + } +} diff --git a/CodenameOne/src/com/codename1/maps/routing/RouteCallback.java b/CodenameOne/src/com/codename1/maps/routing/RouteCallback.java new file mode 100644 index 00000000000..0c9784ae31d --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/RouteCallback.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +import java.util.List; + +/// Receives the outcome of a routing request. Exactly one method is invoked +/// per request, always on the Codename One event dispatch thread, so it is +/// safe to touch the UI directly from either one. +public interface RouteCallback { + + /// Delivers the routes found, best first. The list holds at least one + /// [Route] and only holds more when [RouteRequest#setAlternatives] asked + /// for alternatives and the backend found some. + void routesFound(List routes); + + /// Reports that no route could be produced -- the network failed, the + /// service rejected the request, or no road connects the points (routing + /// across an ocean by car, for example). + /// + /// #### Parameters + /// + /// - `message`: a human readable explanation, never `null` + /// + /// - `error`: the underlying exception when one was thrown, or `null` when + /// the failure carried none -- a request rejected before it was sent, or + /// an error status the service answered with. `message` stands on its own + /// either way; `error` is there for logging and diagnostics. + void routeFailed(String message, Throwable error); +} diff --git a/CodenameOne/src/com/codename1/maps/routing/RouteLeg.java b/CodenameOne/src/com/codename1/maps/routing/RouteLeg.java new file mode 100644 index 00000000000..4bf7da02da3 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/RouteLeg.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// The portion of a [Route] between two consecutive stops. A direct journey +/// has a single leg; each [RouteRequest#addWaypoint] adds another. +public final class RouteLeg { + + private final String summary; + private final double distanceMeters; + private final double durationSeconds; + private final List steps; + + /// Creates a leg. Called by [RouteService] implementations. + /// + /// #### Parameters + /// + /// - `summary`: a short description such as the major roads used + /// + /// - `distanceMeters`: the length of the leg in meters + /// + /// - `durationSeconds`: the estimated travel time in seconds + /// + /// - `steps`: the [RouteStep]s of this leg, defensively copied + public RouteLeg(String summary, double distanceMeters, double durationSeconds, List steps) { + this.summary = summary == null ? "" : summary; + this.distanceMeters = distanceMeters; + this.durationSeconds = durationSeconds; + this.steps = steps == null ? new ArrayList() : new ArrayList(steps); + } + + /// A short description of the leg, typically the major roads it uses. + public String getSummary() { + return summary; + } + + /// The length of this leg in meters. + public double getDistanceMeters() { + return distanceMeters; + } + + /// The estimated travel time for this leg in seconds. + public double getDurationSeconds() { + return durationSeconds; + } + + /// The unmodifiable list of [RouteStep]s making up this leg. Empty when + /// the request had [RouteRequest#setSteps] turned off. + public List getSteps() { + return Collections.unmodifiableList(steps); + } + + /// {@inheritDoc} + @Override + public String toString() { + return "RouteLeg{" + distanceMeters + "m, " + steps.size() + " step(s)}"; + } +} diff --git a/CodenameOne/src/com/codename1/maps/routing/RouteRequest.java b/CodenameOne/src/com/codename1/maps/routing/RouteRequest.java new file mode 100644 index 00000000000..d3d6be46e9c --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/RouteRequest.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +import com.codename1.maps.LatLng; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Describes the journey to route: where it starts, where it ends, anything it +/// must pass through on the way, and how the traveller moves. +/// +/// The setters return `this` so a request reads as one expression: +/// +/// ```java +/// RouteRequest req = new RouteRequest(home, office) +/// .addWaypoint(daycare) +/// .setTravelMode(TravelMode.DRIVING) +/// .setAlternatives(true); +/// ``` +public final class RouteRequest { + + private final LatLng origin; + private final LatLng destination; + private final List waypoints = new ArrayList(); + private TravelMode travelMode = TravelMode.DRIVING; + private boolean alternatives; + private boolean steps = true; + + /// Creates a request for the direct journey from `origin` to + /// `destination`. + public RouteRequest(LatLng origin, LatLng destination) { + this.origin = origin; + this.destination = destination; + } + + /// Where the journey starts. + public LatLng getOrigin() { + return origin; + } + + /// Where the journey ends. + public LatLng getDestination() { + return destination; + } + + /// Adds an intermediate point the route must pass through, in visiting + /// order. Backends route through waypoints in the order added; none of + /// them reorder to optimize the trip. + public RouteRequest addWaypoint(LatLng waypoint) { + if (waypoint != null) { + waypoints.add(waypoint); + } + return this; + } + + /// The unmodifiable intermediate points ([LatLng]) in visiting order; + /// empty for a direct journey. Add to it through [#addWaypoint(LatLng)], + /// which drops a `null`; the view being unmodifiable is what stops anything + /// else being slipped in behind it for a [RouteService] to choke on. + public List getWaypoints() { + return Collections.unmodifiableList(waypoints); + } + + /// How the traveller moves; [TravelMode#DRIVING] unless changed. + public TravelMode getTravelMode() { + return travelMode; + } + + /// Sets how the traveller moves. A `null` mode restores + /// [TravelMode#DRIVING]. + public RouteRequest setTravelMode(TravelMode travelMode) { + this.travelMode = travelMode == null ? TravelMode.DRIVING : travelMode; + return this; + } + + /// Whether the service may return more than one route. + public boolean isAlternatives() { + return alternatives; + } + + /// Asks the service for alternative routes in addition to the best one. + /// Backends treat this as a hint -- most return a single route when no + /// meaningfully different alternative exists. + public RouteRequest setAlternatives(boolean alternatives) { + this.alternatives = alternatives; + return this; + } + + /// Whether turn-by-turn steps are requested; true unless changed. + public boolean isSteps() { + return steps; + } + + /// Requests (or suppresses) turn-by-turn [RouteStep]s. Turn them off when + /// you only need the line on the map -- the response is much smaller. + public RouteRequest setSteps(boolean steps) { + this.steps = steps; + return this; + } + + /// {@inheritDoc} + @Override + public String toString() { + return "RouteRequest{" + origin + " -> " + destination + + ", via " + waypoints.size() + " waypoint(s), " + travelMode + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/maps/routing/RouteService.java b/CodenameOne/src/com/codename1/maps/routing/RouteService.java new file mode 100644 index 00000000000..5478d9c22f3 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/RouteService.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +/// A backend that turns a [RouteRequest] into road-following [Route]s. +/// +/// Codename One ships [OsrmRouteService], which needs no API key, and +/// [Routing] uses it unless you install another. Implement this interface to +/// route through a different provider (Google Directions, Mapbox Directions, +/// GraphHopper, your own server) and install it with +/// [Routing#setService(RouteService)] -- application code that calls +/// [Routing] keeps working unchanged. +public interface RouteService { + + /// A short identifier for this backend, for example `"osrm"`. Used in log + /// messages and to tell services apart. + String getId(); + + /// Whether this service can route right now. A service that needs an API + /// key returns false until one is configured, letting callers fail fast + /// with a clear message instead of a network error. + boolean isAvailable(); + + /// Routes `request` and reports the outcome to `callback`. + /// + /// The call returns immediately; the work happens off the event dispatch + /// thread and the callback is invoked back on it. Implementations must + /// invoke exactly one callback method for every request, including when + /// the request is rejected outright. + /// + /// `callback` is required -- an asynchronous call with nowhere to report + /// its result is a mistake, not a fire-and-forget mode, so implementations + /// reject a `null` one with `IllegalArgumentException` rather than + /// returning quietly and leaving the caller to wonder. + void findRoutes(RouteRequest request, RouteCallback callback); +} diff --git a/CodenameOne/src/com/codename1/maps/routing/RouteStep.java b/CodenameOne/src/com/codename1/maps/routing/RouteStep.java new file mode 100644 index 00000000000..81c622eb116 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/RouteStep.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +import com.codename1.maps.LatLng; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// One maneuver of a [RouteLeg] -- "turn left onto Elm Street and continue for +/// 300 m" -- together with the piece of road geometry it covers. +/// +/// Instances are immutable and are produced by a [RouteService]; applications +/// read them to build a turn-by-turn list beside the map. +public final class RouteStep { + + private final String instruction; + private final String roadName; + private final double distanceMeters; + private final double durationSeconds; + private final LatLng start; + private final List points; + + /// Creates a step. Called by [RouteService] implementations. + /// + /// #### Parameters + /// + /// - `instruction`: a human readable maneuver description + /// + /// - `roadName`: the road this step travels along, possibly empty + /// + /// - `distanceMeters`: the length of the step in meters + /// + /// - `durationSeconds`: the estimated time for the step in seconds + /// + /// - `start`: where the maneuver happens + /// + /// - `points`: the [LatLng] geometry of the step, defensively copied + public RouteStep(String instruction, String roadName, double distanceMeters, + double durationSeconds, LatLng start, List points) { + this.instruction = instruction == null ? "" : instruction; + this.roadName = roadName == null ? "" : roadName; + this.distanceMeters = distanceMeters; + this.durationSeconds = durationSeconds; + this.start = start; + this.points = points == null ? new ArrayList() : new ArrayList(points); + } + + /// A human readable description of the maneuver, for example + /// `"Turn left onto Elm Street"`. Never `null`, but may be empty when the + /// backend supplies no phrasing. + public String getInstruction() { + return instruction; + } + + /// The name of the road travelled during this step; empty when unnamed. + public String getRoadName() { + return roadName; + } + + /// The length of this step in meters. + public double getDistanceMeters() { + return distanceMeters; + } + + /// The estimated time for this step in seconds. + public double getDurationSeconds() { + return durationSeconds; + } + + /// Where the maneuver takes place, or `null` when the backend omits it. + public LatLng getStart() { + return start; + } + + /// The unmodifiable [LatLng] geometry travelled by this step, useful to + /// highlight the upcoming maneuver on the map. + public List getPoints() { + return Collections.unmodifiableList(points); + } + + /// {@inheritDoc} + @Override + public String toString() { + return "RouteStep{" + instruction + ", " + distanceMeters + "m}"; + } +} diff --git a/CodenameOne/src/com/codename1/maps/routing/Routing.java b/CodenameOne/src/com/codename1/maps/routing/Routing.java new file mode 100644 index 00000000000..8897f3c7e27 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/Routing.java @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapSurface; +import com.codename1.ui.CN; + +import java.util.List; + +/// The entry point for road-following routes. +/// +/// A [com.codename1.maps.Polyline] joins the points you give it with straight +/// lines. To draw the road a driver would actually take you need a routing +/// service to work out the road geometry first, which is what this class does: +/// +/// ```java +/// MapView map = new MapView(); +/// Routing.showRoute(map, new LatLng(38.8977, -77.0365), new LatLng(38.8894, -77.0352)); +/// ``` +/// +/// Routing is asynchronous -- the call returns immediately and the line +/// appears once the service answers. For control over the result use +/// [#findRoute(RouteRequest, RouteCallback)]: +/// +/// ```java +/// Routing.findRoute(new RouteRequest(origin, destination), new RouteCallback() { +/// public void routesFound(List routes) { +/// Route best = (Route)routes.get(0); +/// map.addPolyline(best.toPolyline().setStrokeColor(0xff5722)); +/// map.fitBounds(best.getBounds(), 40); +/// distanceLabel.setText((int)(best.getDistanceMeters() / 1000) + " km"); +/// } +/// +/// public void routeFailed(String message, Throwable error) { +/// ToastBar.showErrorMessage(message); +/// } +/// }); +/// ``` +/// +/// The work is done by a [RouteService]. Unless you install another, that is +/// the keyless [OsrmRouteService] -- read its documentation before shipping, +/// since its default endpoint is a public demo server. +public final class Routing { + + private static RouteService service; + + private Routing() { + } + + /// The service backing every routing call, creating the default keyless + /// [OsrmRouteService] on first use. + public static synchronized RouteService getService() { + if (service == null) { + service = new OsrmRouteService(); + } + return service; + } + + /// Installs the service backing every routing call. Pass `null` to restore + /// the default [OsrmRouteService]. + public static synchronized void setService(RouteService service) { + Routing.service = service; + } + + /// Routes from `origin` to `destination` by car and reports the outcome to + /// `callback`. + public static void findRoute(LatLng origin, LatLng destination, RouteCallback callback) { + findRoute(new RouteRequest(origin, destination), callback); + } + + /// Routes `request` and reports the outcome to `callback`. + /// + /// Returns immediately; `callback` is invoked later on the event dispatch + /// thread, exactly once. A service that reports itself unavailable -- one + /// still waiting for an API key, say -- fails the request here with a + /// readable message instead of letting it turn into a network error, so + /// callers never have to check [RouteService#isAvailable()] themselves. + /// + /// Throws `IllegalArgumentException` when `callback` is `null`; an + /// asynchronous call has no other way to reach you. + public static void findRoute(RouteRequest request, final RouteCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback is required: routing is asynchronous, " + + "so there is no other way to report the result"); + } + final OnceOnly guarded = new OnceOnly(callback); + try { + // Everything that touches the service belongs inside the guard, + // not just the routing call: resolving it, asking whether it is + // ready and reading its id are all third-party code, and any of + // them throwing would otherwise escape a method that promises the + // caller exactly one asynchronous answer. + RouteService routeService = getService(); + if (!routeService.isAvailable()) { + String reported = routeService.getId(); + // A service is supposed to have an id, but quoting a null or + // blank one back at the user reads like a bug in the message. + final String id = reported == null || reported.length() == 0 + ? "unknown" : reported; + guarded.routeFailed("The routing service '" + id + + "' is not ready to route; it may still need to be configured", null); + return; + } + routeService.findRoutes(request, guarded); + } catch (RuntimeException e) { + // A service is required to answer through the callback, but this + // facade promises the app exactly one answer and cannot rely on a + // third-party implementation keeping its side of the bargain. The + // wrapper latches, so this is a no-op when the service already + // reported, and it handles getting onto the EDT. + guarded.routeFailed("The routing service failed: " + + (e.getMessage() == null ? e.toString() : e.getMessage()), e); + } + } + + /// Enforces both halves of what [#findRoute(RouteRequest, RouteCallback)] + /// promises the application -- exactly one outcome, on the event dispatch + /// thread -- no matter how the [RouteService] behind it behaves. + /// + /// The SPI requires implementations to deliver once and on the EDT, but a + /// facade cannot assume a third-party implementation honors either. A + /// service that reports from a background thread, reports twice, or + /// reports concurrently with throwing would otherwise push its bug + /// straight through to application code that has every right to expect + /// the documented behavior. So the latch is claimed under a lock rather + /// than with a plain field read, and whatever wins is re-dispatched onto + /// the EDT (passed straight through when already there, so the common + /// case costs nothing). + private static final class OnceOnly implements RouteCallback { + + private final RouteCallback delegate; + private boolean delivered; + + OnceOnly(RouteCallback delegate) { + this.delegate = delegate; + } + + /// Claims the single delivery. Returns true for exactly one caller + /// however many threads race here. + private synchronized boolean claim() { + if (delivered) { + return false; + } + delivered = true; + return true; + } + + @Override + public void routesFound(final List routes) { + if (!claim()) { + return; + } + onEdt(new Runnable() { + @Override + public void run() { + delegate.routesFound(routes); + } + }); + } + + @Override + public void routeFailed(final String message, final Throwable error) { + if (!claim()) { + return; + } + onEdt(new Runnable() { + @Override + public void run() { + delegate.routeFailed(message, error); + } + }); + } + + /// Always queues rather than running inline when already on the EDT. + /// The contract is "returns immediately, answers later", and a + /// service that happens to answer synchronously would otherwise run + /// the application's callback before `findRoute` had returned -- the + /// caller would be re-entered mid-setup, and the timing would differ + /// between services for no reason the caller can see. + private static void onEdt(Runnable r) { + CN.callSerially(r); + } + } + + /// Draws the best route between `origin` and `destination` on `map` and + /// frames it, with no further code required. + /// + /// This is the least code that gets you from two coordinates to a line + /// following the roads. Failures are silent -- when you need to report + /// them, or to style the line, use + /// [#showRoute(MapSurface, RouteRequest, RouteCallback)]. + /// + /// #### Parameters + /// + /// - `map`: the map to draw on + /// + /// - `origin`: where the journey starts + /// + /// - `destination`: where the journey ends + public static void showRoute(MapSurface map, LatLng origin, LatLng destination) { + showRoute(map, new RouteRequest(origin, destination), null); + } + + /// Draws the best route for `request` on `map`, frames it, and forwards + /// the outcome to `callback`. + /// + /// The polyline is added and the camera moved *before* `callback` runs, so + /// the callback can read the route's distance and duration to update the + /// UI. It cannot restyle the line that was drawn -- that polyline is not + /// exposed, and [Route#toPolyline()] hands back a fresh one every call. To + /// control how the route looks, skip this method: call + /// [#findRoute(RouteRequest, RouteCallback)] and add the styled polyline + /// yourself. + /// + /// #### Parameters + /// + /// - `map`: the map to draw on + /// + /// - `request`: the journey to route + /// + /// - `callback`: notified of the outcome, or `null` to just draw the route + public static void showRoute(final MapSurface map, RouteRequest request, + final RouteCallback callback) { + findRoute(request, new RouteCallback() { + @Override + public void routesFound(List routes) { + if (routes == null || routes.isEmpty()) { + // A service that breaks the contract shouldn't surface as + // an IndexOutOfBoundsException in the middle of the EDT. + routeFailed("The routing service returned no route", null); + return; + } + Route best = (Route) routes.get(0); + map.addPolyline(best.toPolyline()); + if (best.getBounds() != null) { + map.fitBounds(best.getBounds(), CN.convertToPixels(4)); + } + if (callback != null) { + callback.routesFound(routes); + } + } + + @Override + public void routeFailed(String message, Throwable error) { + if (callback != null) { + callback.routeFailed(message, error); + } + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/maps/routing/TravelMode.java b/CodenameOne/src/com/codename1/maps/routing/TravelMode.java new file mode 100644 index 00000000000..07c364413ca --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/TravelMode.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +/// How the traveller moves, which decides the road network and speeds a +/// [RouteService] routes over. +/// +/// Not every backend implements every mode. A service that cannot honor the +/// requested mode answers with the closest profile it does have rather than +/// failing the request, so check the [RouteService] you installed before +/// promising a user a walking route -- see [OsrmRouteService] for how the +/// built-in default behaves. +public enum TravelMode { + + /// Route over roads open to cars, respecting one-way streets and turn + /// restrictions. + DRIVING("driving"), + + /// Route over footpaths and pedestrian crossings, ignoring one-way + /// restrictions that do not apply on foot. + WALKING("walking"), + + /// Route over cycleways and bike-legal roads. + CYCLING("cycling"); + + private final String id; + + TravelMode(String id) { + this.id = id; + } + + /// The lowercase wire identifier (`driving`, `walking`, `cycling`) used in + /// routing service URLs. + public String getId() { + return id; + } +} diff --git a/CodenameOne/src/com/codename1/maps/routing/package-info.java b/CodenameOne/src/com/codename1/maps/routing/package-info.java new file mode 100644 index 00000000000..012c4f89842 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/routing/package-info.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Road-following routes for the modern maps API. +/// +/// A [com.codename1.maps.Polyline] connects the coordinates you hand it with +/// straight segments, which is rarely what a navigation screen wants. This +/// package works out the geometry of the roads between two points so the line +/// on the map traces the drive a user would actually make. +/// +/// [com.codename1.maps.routing.Routing] is the entry point and works with no +/// configuration -- it routes over OpenStreetMap data through the keyless +/// [com.codename1.maps.routing.OsrmRouteService], mirroring how +/// [com.codename1.maps.MapView] draws the keyless OpenFreeMap basemap: +/// +/// ```java +/// MapView map = new MapView(); +/// Routing.showRoute(map, origin, destination); +/// ``` +/// +/// A [com.codename1.maps.routing.RouteRequest] adds waypoints and a +/// [com.codename1.maps.routing.TravelMode]; the resulting +/// [com.codename1.maps.routing.Route] carries the drawable geometry, the total +/// distance and duration, and the +/// [com.codename1.maps.routing.RouteLeg]/[com.codename1.maps.routing.RouteStep] +/// breakdown behind a turn-by-turn list. +/// +/// The default service is backed by OSRM's public *demo* server, which is fine +/// for development but has no SLA. Production apps point it at their own +/// instance, or install a different backend entirely by implementing +/// [com.codename1.maps.routing.RouteService] and passing it to +/// [com.codename1.maps.routing.Routing#setService] -- app code that calls +/// `Routing` is unaffected either way. +package com.codename1.maps.routing; diff --git a/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java b/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java index d20148b79c9..7a8d9124035 100644 --- a/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java +++ b/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java @@ -228,13 +228,7 @@ public void panPixels(double dx, double dy) { double cwx = WebMercator.lonToWorldX(centerLon, zoom) - dx / pixelRatio; double cwy = WebMercator.latToWorldY(centerLat, zoom) - dy / pixelRatio; centerLon = WebMercator.worldXToLon(cwx, zoom); - double lat = WebMercator.worldYToLat(cwy, zoom); - if (lat > 85.05112878) { - lat = 85.05112878; - } else if (lat < -85.05112878) { - lat = -85.05112878; - } - centerLat = lat; + centerLat = WebMercator.clampLatitude(WebMercator.worldYToLat(cwy, zoom)); } /// Zooms to `newZoom` while keeping the geographic point currently under @@ -273,14 +267,27 @@ public void fitBounds(MapBounds bounds, int padding) { } // Convert the usable device-pixel viewport to logical map pixels (the // unit worldSpanX/Y are in) before solving for the zoom that fits. - double usableW = Math.max(1, viewWidth - 2 * padding) / pixelRatio; - double usableH = Math.max(1, viewHeight - 2 * padding) / pixelRatio; + // These "nothing to fit" cases keep the current zoom and merely + // recenter, matching NativeMap.zoomToFit so that MapSurface#fitBounds + // means the same thing on both surfaces. double worldW = worldSpanX(bounds); double worldH = worldSpanY(bounds); - double zx = worldW <= 0 ? getMaxZoom() : log2(usableW / worldW); - double zy = worldH <= 0 ? getMaxZoom() : log2(usableH / worldH); - setZoom(Math.min(zx, zy)); - setCenter(bounds.getCenter()); + boolean roomToFit = viewWidth - 2 * padding > 0 && viewHeight - 2 * padding > 0; + if (roomToFit && (worldW > 0 || worldH > 0)) { + double usableW = (viewWidth - 2 * padding) / pixelRatio; + double usableH = (viewHeight - 2 * padding) / pixelRatio; + double zx = worldW <= 0 ? Double.MAX_VALUE : log2(usableW / worldW); + double zy = worldH <= 0 ? Double.MAX_VALUE : log2(usableH / worldH); + setZoom(Math.min(zx, zy)); + } + // The vertical center has to be the projected midpoint, not the mean + // of the two latitudes: Mercator stretches away from the equator, so + // centering a tall band on its arithmetic middle leaves the poleward + // edge outside the viewport the zoom above just sized for it. + setCenter(new LatLng( + WebMercator.centerLatitude(bounds.getSouthWest().getLatitude(), + bounds.getNorthEast().getLatitude()), + bounds.getCenter().getLongitude())); } private double worldSpanX(MapBounds b) { @@ -290,8 +297,12 @@ private double worldSpanX(MapBounds b) { } private double worldSpanY(MapBounds b) { - double y0 = WebMercator.latToWorldY(b.getSouthWest().getLatitude(), 0); - double y1 = WebMercator.latToWorldY(b.getNorthEast().getLatitude(), 0); + // Clamped, or a bound touching a pole projects to infinity and the + // solved zoom collapses. + double y0 = WebMercator.latToWorldY( + WebMercator.clampLatitude(b.getSouthWest().getLatitude()), 0); + double y1 = WebMercator.latToWorldY( + WebMercator.clampLatitude(b.getNorthEast().getLatitude()), 0); return Math.abs(y1 - y0); } diff --git a/CodenameOne/src/com/codename1/maps/vector/WebMercator.java b/CodenameOne/src/com/codename1/maps/vector/WebMercator.java index 35f7d08ffc1..0f4581463f8 100644 --- a/CodenameOne/src/com/codename1/maps/vector/WebMercator.java +++ b/CodenameOne/src/com/codename1/maps/vector/WebMercator.java @@ -72,6 +72,45 @@ public static double worldYToLat(double worldY, double zoom) { return latRad * 180.0 / PI; } + /// The furthest latitude Web Mercator can represent. The projection runs + /// to infinity at the poles, so every slippy map cuts off here, which also + /// makes the world exactly square. + public static final double MAX_LATITUDE = 85.05112878; + + /// Confines `lat` to the range the projection can actually represent. + /// + /// Projecting a latitude beyond [#MAX_LATITUDE] produces an enormous or + /// infinite y, and arithmetic on those values quietly yields nonsense: + /// averaging the projections of -90 and 90 lands on the south pole rather + /// than the equator. Clamping first keeps every derived camera finite and + /// sensible. + public static double clampLatitude(double lat) { + if (lat > MAX_LATITUDE) { + return MAX_LATITUDE; + } + if (lat < -MAX_LATITUDE) { + return -MAX_LATITUDE; + } + return lat; + } + + /// The latitude that sits visually halfway between `southLat` and + /// `northLat` on a Mercator map -- the midpoint of their *projected* y + /// coordinates, converted back. Both inputs are clamped to + /// [#MAX_LATITUDE] first. + /// + /// This is not the arithmetic mean, because Mercator stretches distances + /// away from the equator. Latitudes 0 and 80 average to 40, but their + /// projected midpoint is about 57; centering a camera on 40 pushes the + /// northern edge of that band well outside a viewport sized to hold it. + /// Anything framing bounds must center this way for the fitted zoom to + /// actually contain them. + public static double centerLatitude(double southLat, double northLat) { + double midY = (latToWorldY(clampLatitude(southLat), 0) + + latToWorldY(clampLatitude(northLat), 0)) / 2.0; + return worldYToLat(midY, 0); + } + /// Hyperbolic sine, absent from the minimal device `Math`. public static double sinh(double x) { return (MathUtil.exp(x) - MathUtil.exp(-x)) / 2.0; diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava005Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava005Snippet.java new file mode 100644 index 00000000000..eb644495359 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava005Snippet.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.maps.routing.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class MapsJava005Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + LatLng origin = new LatLng(38.8977, -77.0365); + LatLng destination = new LatLng(38.8894, -77.0352); + void snippet() throws Exception { + // tag::maps-java-005[] + MapView map = new MapView(); + form.add(BorderLayout.CENTER, map); + + // Draws the driving route along the actual roads and frames it. + Routing.showRoute(map, origin, destination); + // end::maps-java-005[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava006Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava006Snippet.java new file mode 100644 index 00000000000..524b842d072 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava006Snippet.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.maps.routing.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class MapsJava006Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + LatLng origin = new LatLng(38.8977, -77.0365); + LatLng destination = new LatLng(38.8894, -77.0352); + final MapView map = new MapView(); + void snippet() throws Exception { + // tag::maps-java-006[] + RouteRequest request = new RouteRequest(origin, destination) + .addWaypoint(new LatLng(38.8899, -77.0091)) + .setTravelMode(TravelMode.DRIVING); + + Routing.findRoute(request, new RouteCallback() { + @Override + public void routesFound(java.util.List routes) { + Route best = (Route)routes.get(0); + map.addPolyline(best.toPolyline().setStrokeColor(0xff5722).setStrokeWidth(6)); + map.fitBounds(best.getBounds(), CN.convertToPixels(4)); + label.setText((int)(best.getDistanceMeters() / 1000) + " km, " + + (int)(best.getDurationSeconds() / 60) + " min"); + } + + @Override + public void routeFailed(String message, Throwable error) { + label.setText(message); + } + }); + // end::maps-java-006[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava007Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava007Snippet.java new file mode 100644 index 00000000000..a923b61574e --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava007Snippet.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.maps.routing.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class MapsJava007Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::maps-java-007[] + // Your own OSRM instance, or any OSRM-compatible endpoint. + Routing.setService(new OsrmRouteService("https://osrm.example.com")); + // end::maps-java-007[] + } +} diff --git a/docs/developer-guide/Maps.asciidoc b/docs/developer-guide/Maps.asciidoc index ba2b596e9c6..75aa846bbd3 100644 --- a/docs/developer-guide/Maps.asciidoc +++ b/docs/developer-guide/Maps.asciidoc @@ -31,6 +31,35 @@ Marker icons are supplied as `EncodedImage` and anchored in normalized `(u, v)` image::img/maps-markers.png[Markers drawn with the default Material map pin,scaledwidth=40%] +=== Routes that follow the road + +A polyline joins the coordinates you hand it with *straight* lines -- it knows nothing about roads. Passing two endpoints to `addPolyline` therefore draws a single straight segment across the map, which is seldom what a navigation screen wants. Working out the road geometry between two points is a separate job, done by a routing service, and that's what the `com.codename1.maps.routing` package is for: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava005Snippet.java[tag=maps-java-005,indent=0] +---- + +`Routing.showRoute(...)` fetches the route, draws it and moves the camera to frame it. Like the keyless OpenFreeMap basemap, it works with no API key and no signup: routing runs over OpenStreetMap data through the built-in `OsrmRouteService`. + +Routing is asynchronous -- the call returns immediately and the line appears when the service answers. Use `Routing.findRoute(...)` when you want the result itself, for example to style the line or show the distance and the estimated time: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava006Snippet.java[tag=maps-java-006,indent=0] +---- + +A `Route` carries the drawable geometry (`toPolyline()`), the bounds to frame it with, the total distance and duration, and the `RouteLeg`/`RouteStep` breakdown behind a turn-by-turn list. A `RouteRequest` adds waypoints and a `TravelMode` (`DRIVING`, `WALKING`, `CYCLING`). + +CAUTION: The default endpoint is OSRM's *public demo server*. It exists for development and demos: no SLA, rate limited, and it hosts the car profile, so walking and cycling requests are routed over driving data there. Before shipping, point the service at your own OSRM instance (or any OSRM-compatible endpoint) -- nothing else in your code changes: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MapsJava007Snippet.java[tag=maps-java-007,indent=0] +---- + +To route through a different provider entirely -- Google Directions, Mapbox Directions, GraphHopper, your own server -- implement `RouteService` and install it the same way. Every provider of that kind returns the route as an *encoded polyline*, which `Polyline.fromEncoded(geometry)` turns straight into something drawable (`PolylineCodec` also handles the `polyline6` variant used by Valhalla). + === Tile sources and styles (MapView) `MapView` pulls its tiles from a pluggable `com.codename1.maps.vector.TileSource`: diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m index 365cbd1edac..7cc07092159 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + /* * Codename One maps provider -- Apple MapKit (iOS). * @@ -143,22 +166,42 @@ - (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id 0 ? w : 256.0; +} + +static float spanToZoom(double lonDelta, double widthPoints) { if (lonDelta <= 0) { return 0; } - return (float)(log(360.0 / lonDelta) / log(2.0)); + return (float)(log(360.0 * widthPoints / (256.0 * lonDelta)) / log(2.0)); } -static double zoomToSpan(float zoom) { - return 360.0 / pow(2.0, zoom); +static double zoomToSpan(float zoom, double widthPoints) { + return 360.0 * widthPoints / (256.0 * pow(2.0, zoom)); } JAVA_LONG com_codename1_maps_MapProviderImpl_nativeCreate___int_double_double_float_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_FLOAT zoom) { __block CN1AppleMap *m = nil; - double span = zoomToSpan((float)zoom); void (^createBlock)(void) = ^{ m = [[CN1AppleMap alloc] initWithMapId:(int)mapId]; + // Resolved after the view exists so its width can be used when it + // already has one. + double span = zoomToSpan((float)zoom, cn1MapWidthPoints(m)); MKCoordinateRegion region = MKCoordinateRegionMake( CLLocationCoordinate2DMake(lat, lon), MKCoordinateSpanMake(span, span)); [m.mapView setRegion:region animated:NO]; @@ -182,8 +225,10 @@ void com_codename1_maps_MapProviderImpl_nativeDeinit___int(CN1_THREAD_STATE_MULT void com_codename1_maps_MapProviderImpl_nativeSetCamera___int_double_double_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_FLOAT zoom) { CN1AppleMap *m = cn1MapFor((int)mapId); if (!m) { return; } - double span = zoomToSpan((float)zoom); dispatch_async(dispatch_get_main_queue(), ^{ + // The view's width is only safe to read on the main thread, and it is + // needed to turn a slippy zoom into a MapKit span. + double span = zoomToSpan((float)zoom, cn1MapWidthPoints(m)); MKCoordinateRegion region = MKCoordinateRegionMake( CLLocationCoordinate2DMake(lat, lon), MKCoordinateSpanMake(span, span)); [m.mapView setRegion:region animated:YES]; @@ -202,7 +247,7 @@ JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeGetLon___int_R_double(CN1_T JAVA_FLOAT com_codename1_maps_MapProviderImpl_nativeGetZoom___int_R_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { CN1AppleMap *m = cn1MapFor((int)mapId); - return m ? spanToZoom(m.mapView.region.span.longitudeDelta) : 0; + return m ? spanToZoom(m.mapView.region.span.longitudeDelta, cn1MapWidthPoints(m)) : 0; } JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddMarker___int_double_double_java_lang_String_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_OBJECT title) { diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index 59a27e2691d..70f3ede56a1 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -295,6 +295,17 @@ + + + + + + diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java index 289c0332049..77b81466070 100644 --- a/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java @@ -6,6 +6,19 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.maps; @@ -19,6 +32,7 @@ import com.codename1.maps.spi.MapProvider; import com.codename1.maps.spi.MapProviderRegistry; +import com.codename1.maps.vector.WebMercator; import com.codename1.ui.PeerComponent; import java.util.ArrayList; import java.util.List; @@ -233,6 +247,96 @@ public boolean isAvailable() { assertNotNull(MapProviderRegistry.getProvider()); } + // ---- NativeMap bounds fitting ---------------------------------------- + + @Test + void nativeMapSolvesTheZoomThatFitsBounds() { + // The whole world spans exactly one 256pt tile at zoom 0, so a 256px + // viewport fits it at zoom 0 and every doubling adds one level. + MapBounds world = new MapBounds(new LatLng(-WebMercator.MAX_LATITUDE, -180), + new LatLng(WebMercator.MAX_LATITUDE, 180)); + assertEquals(0.0, NativeMap.zoomToFit(world, 256, 256, 0, 1.0), 1e-6); + assertEquals(1.0, NativeMap.zoomToFit(world, 512, 512, 0, 1.0), 1e-6); + assertEquals(2.0, NativeMap.zoomToFit(world, 1024, 1024, 0, 1.0), 1e-6); + + // Padding shrinks the usable viewport, and so the zoom. + assertEquals(0.0, NativeMap.zoomToFit(world, 512, 512, 128, 1.0), 1e-6); + + // Native zoom counts logical points, so a 2x density screen fits the + // same region at one level less than its device-pixel size suggests. + assertEquals(0.0, NativeMap.zoomToFit(world, 512, 512, 0, 2.0), 1e-6); + + // The tighter of the two axes wins: a wide, short viewport is limited + // by its height here. + double fit = NativeMap.zoomToFit(world, 2048, 256, 0, 1.0); + assertEquals(0.0, fit, 1e-6); + } + + @Test + void mercatorCenterIsTheProjectedMidpointNotTheMean() { + // Mercator stretches away from the equator, so a band from 0 to 80 + // is visually centred near 57, not 40. Centring on the mean leaves the + // northern edge outside a viewport the fitted zoom just sized for it. + assertEquals(57.045, WebMercator.centerLatitude(0, 80), 1e-3); + assertEquals(-57.045, WebMercator.centerLatitude(-80, 0), 1e-3); + + // Symmetric bands still centre on the equator, and a degenerate band + // on itself. + assertEquals(0.0, WebMercator.centerLatitude(-45, 45), 1e-9); + assertEquals(37.7749, WebMercator.centerLatitude(37.7749, 37.7749), 1e-6); + + // Near the equator the distortion is negligible, so it stays close to + // the arithmetic mean. + assertEquals(1.0, WebMercator.centerLatitude(0, 2), 1e-3); + } + + @Test + void mercatorCenterSurvivesThePoles() { + // Mercator runs to infinity at the poles, so projecting +-90 and + // averaging produced nonsense: a pole-to-pole span used to centre on + // the south pole rather than the equator. + assertEquals(0.0, WebMercator.centerLatitude(-90, 90), 1e-9); + assertEquals(WebMercator.MAX_LATITUDE, WebMercator.centerLatitude(90, 90), 1e-6); + assertEquals(-WebMercator.MAX_LATITUDE, WebMercator.centerLatitude(-90, -90), 1e-6); + assertTrue(WebMercator.centerLatitude(0, 90) < WebMercator.MAX_LATITUDE); + + assertEquals(WebMercator.MAX_LATITUDE, WebMercator.clampLatitude(90), 1e-9); + assertEquals(-WebMercator.MAX_LATITUDE, WebMercator.clampLatitude(-90), 1e-9); + assertEquals(12.5, WebMercator.clampLatitude(12.5), 1e-9); + } + + @Test + void nativeMapReportsNoFitWhenThereIsNothingToFitTo() { + MapBounds world = new MapBounds(new LatLng(-85, -180), new LatLng(85, 180)); + // Before layout there is no viewport to solve against. + assertTrue(Double.isNaN(NativeMap.zoomToFit(world, 0, 0, 0, 1.0))); + assertTrue(Double.isNaN(NativeMap.zoomToFit(world, 256, 0, 0, 1.0))); + assertTrue(Double.isNaN(NativeMap.zoomToFit(null, 256, 256, 0, 1.0))); + assertTrue(Double.isNaN(NativeMap.zoomToFit(world, 256, 256, 0, 0))); + + // A single point has no extent, so no zoom "fits" it. + MapBounds point = new MapBounds(new LatLng(37.7749, -122.4194), + new LatLng(37.7749, -122.4194)); + assertTrue(Double.isNaN(NativeMap.zoomToFit(point, 256, 256, 0, 1.0))); + + // Padding that swallows the viewport leaves nothing to fit into; + // solving against a forced one-pixel window would give an absurd zoom. + assertTrue(Double.isNaN(NativeMap.zoomToFit(world, 256, 256, 128, 1.0))); + assertTrue(Double.isNaN(NativeMap.zoomToFit(world, 256, 256, 200, 1.0))); + assertFalse(Double.isNaN(NativeMap.zoomToFit(world, 256, 256, 127, 1.0))); + } + + @Test + void nativeMapZoomsFurtherForASmallerRegion() { + // A tighter region must fit at a higher zoom than a wider one; this is + // the property that was missing when fitBounds only recentered. + MapBounds city = new MapBounds(new LatLng(37.70, -122.52), new LatLng(37.83, -122.35)); + MapBounds block = new MapBounds(new LatLng(37.7740, -122.4200), new LatLng(37.7760, -122.4180)); + double cityZoom = NativeMap.zoomToFit(city, 800, 800, 0, 1.0); + double blockZoom = NativeMap.zoomToFit(block, 800, 800, 0, 1.0); + assertTrue(blockZoom > cityZoom, "block " + blockZoom + " should out-zoom city " + cityZoom); + } + /** A no-op MapProvider used to exercise the registry without any native peer. */ private static class StubProvider implements MapProvider { private final String id; diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/PolylineCodecTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/PolylineCodecTest.java new file mode 100644 index 00000000000..481e24d93a1 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/PolylineCodecTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Unit tests for the encoded polyline codec and the Polyline factory built on it. */ +class PolylineCodecTest { + + /** + * The worked example from Google's encoded polyline specification: + * (38.5, -120.2), (40.7, -120.95), (43.252, -126.453). + */ + private static final String GOOGLE_SAMPLE = "_p~iF~ps|U_ulLnnqC_mqNvxq`@"; + + @Test + void decodesTheSpecificationSample() { + List points = PolylineCodec.decode(GOOGLE_SAMPLE); + assertEquals(3, points.size()); + assertLatLng(38.5, -120.2, points.get(0)); + assertLatLng(40.7, -120.95, points.get(1)); + assertLatLng(43.252, -126.453, points.get(2)); + } + + @Test + void encodesTheSpecificationSample() { + List points = new ArrayList(); + points.add(new LatLng(38.5, -120.2)); + points.add(new LatLng(40.7, -120.95)); + points.add(new LatLng(43.252, -126.453)); + assertEquals(GOOGLE_SAMPLE, PolylineCodec.encode(points)); + } + + @Test + void roundTripsAtBothPrecisions() { + List points = new ArrayList(); + points.add(new LatLng(38.897700, -77.036500)); + points.add(new LatLng(38.889400, -77.035200)); + points.add(new LatLng(-33.866000, 151.195000)); + + List five = PolylineCodec.decode(PolylineCodec.encode(points, 5), 5); + List six = PolylineCodec.decode(PolylineCodec.encode(points, 6), 6); + assertEquals(points.size(), five.size()); + assertEquals(points.size(), six.size()); + for (int i = 0; i < points.size(); i++) { + LatLng expected = (LatLng) points.get(i); + assertLatLng(expected.getLatitude(), expected.getLongitude(), five.get(i)); + assertLatLng(expected.getLatitude(), expected.getLongitude(), six.get(i)); + } + } + + @Test + void precisionSixIsTenTimesFinerThanPrecisionFive() { + // Decoding a polyline6 geometry as precision 5 is the classic mistake; + // it must move the coordinate by exactly a factor of ten, not throw. + List points = new ArrayList(); + points.add(new LatLng(4.5, 5.5)); + String encoded = PolylineCodec.encode(points, 6); + assertLatLng(45.0, 55.0, PolylineCodec.decode(encoded, 5).get(0)); + } + + @Test + void toleratesNullEmptyAndTruncatedInput() { + assertTrue(PolylineCodec.decode(null).isEmpty()); + assertTrue(PolylineCodec.decode("").isEmpty()); + assertEquals("", PolylineCodec.encode(null)); + + // A geometry cut in half mid-response yields the complete coordinates + // it did contain rather than blowing up. + List truncated = PolylineCodec.decode("_p~iF~ps|U_ulL"); + assertEquals(1, truncated.size()); + assertLatLng(38.5, -120.2, truncated.get(0)); + } + + @Test + void dropsAValueCutInHalfRatherThanEmittingABogusPoint() { + // Losing the last byte truncates the final longitude mid-value. The + // accumulated bits are a fraction of the real delta, so emitting them + // would draw a spurious segment and widen the route bounds; the point + // has to be dropped instead. + String cut = GOOGLE_SAMPLE.substring(0, GOOGLE_SAMPLE.length() - 1); + List points = PolylineCodec.decode(cut); + assertEquals(2, points.size()); + assertLatLng(38.5, -120.2, points.get(0)); + assertLatLng(40.7, -120.95, points.get(1)); + } + + @Test + void stopsAtCharactersOutsideTheEncodingAlphabet() { + // A byte below the ASCII offset decodes to a negative chunk, and a + // negative chunk reads as "terminating", so junk in the middle of a + // geometry used to close the value early and emit a bogus coordinate. + List withSpace = PolylineCodec.decode("_p~iF~ps|U _ulLnnqC"); + assertEquals(1, withSpace.size()); + assertLatLng(38.5, -120.2, withSpace.get(0)); + + // Above the alphabet too -- a stray high byte is not a chunk. + List withHighByte = PolylineCodec.decode("_p~iF~ps|Uÿ_ulLnnqC"); + assertEquals(1, withHighByte.size()); + assertLatLng(38.5, -120.2, withHighByte.get(0)); + + assertTrue(PolylineCodec.decode("!!!!").isEmpty()); + } + + @Test + void rejectsAPrecisionThatCannotScaleCoordinates() { + // Precision 0 or less leaves the scale at 1 and inflates every + // coordinate; a huge one overflows the scale and collapses them to + // zero. Both used to decode silently into a map of wrong places. + assertThrows(IllegalArgumentException.class, () -> PolylineCodec.decode(GOOGLE_SAMPLE, 0)); + assertThrows(IllegalArgumentException.class, () -> PolylineCodec.decode(GOOGLE_SAMPLE, -5)); + assertThrows(IllegalArgumentException.class, () -> PolylineCodec.decode(GOOGLE_SAMPLE, 400)); + assertThrows(IllegalArgumentException.class, + () -> PolylineCodec.encode(new ArrayList(), 0)); + assertThrows(IllegalArgumentException.class, () -> Polyline.fromEncoded(GOOGLE_SAMPLE, 0)); + + // The check runs before the empty-input short-circuit, so a bad + // argument fails the same way whatever the payload. + assertThrows(IllegalArgumentException.class, () -> PolylineCodec.decode(null, 0)); + assertThrows(IllegalArgumentException.class, () -> PolylineCodec.encode(null, 0)); + + // The precisions services actually emit stay accepted. + assertEquals(3, PolylineCodec.decode(GOOGLE_SAMPLE, 5).size()); + assertEquals(3, PolylineCodec.decode(GOOGLE_SAMPLE, 6).size()); + assertEquals(3, PolylineCodec.decode(GOOGLE_SAMPLE, 7).size()); + } + + @Test + void polylineFactoryDecodesGeometry() { + Polyline pl = Polyline.fromEncoded(GOOGLE_SAMPLE); + assertEquals(3, pl.getPoints().size()); + assertLatLng(38.5, -120.2, pl.getPoints().get(0)); + + assertTrue(Polyline.fromEncoded(null).getPoints().isEmpty()); + } + + private static void assertLatLng(double lat, double lon, Object actual) { + LatLng p = (LatLng) actual; + assertEquals(lat, p.getLatitude(), 1e-6); + assertEquals(lon, p.getLongitude(), 1e-6); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/routing/MapsRoutingTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/routing/MapsRoutingTest.java new file mode 100644 index 00000000000..3ded31d0f79 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/routing/MapsRoutingTest.java @@ -0,0 +1,539 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.routing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapBounds; +import com.codename1.maps.Polyline; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for the routing model, the OSRM request/response handling and the Routing facade. */ +class MapsRoutingTest { + + /** The Google specification sample: (38.5, -120.2), (40.7, -120.95), (43.252, -126.453). */ + private static final String GEOMETRY = "_p~iF~ps|U_ulLnnqC_mqNvxq`@"; + + @AfterEach + void restoreDefaultService() { + Routing.setService(null); + } + + // ---- Request ---------------------------------------------------------- + + @Test + void requestDefaultsToDrivingWithSteps() { + RouteRequest req = new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)); + assertEquals(TravelMode.DRIVING, req.getTravelMode()); + assertTrue(req.isSteps()); + assertFalse(req.isAlternatives()); + assertTrue(req.getWaypoints().isEmpty()); + + assertSame(req, req.setTravelMode(null)); + assertEquals(TravelMode.DRIVING, req.getTravelMode(), "a null mode falls back to driving"); + } + + @Test + void requestKeepsWaypointsInVisitingOrderAndIgnoresNull() { + RouteRequest req = new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)) + .addWaypoint(new LatLng(5, 6)) + .addWaypoint(null) + .addWaypoint(new LatLng(7, 8)); + assertEquals(2, req.getWaypoints().size()); + assertEquals(new LatLng(5, 6), req.getWaypoints().get(0)); + assertEquals(new LatLng(7, 8), req.getWaypoints().get(1)); + } + + @Test + void waypointsCannotBeCorruptedThroughTheGetter() { + // Handing out the live list would let a caller slip past addWaypoint's + // null filtering and blow up later inside buildUrl. + RouteRequest req = new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)); + assertThrows(UnsupportedOperationException.class, () -> req.getWaypoints().add("not a LatLng")); + assertTrue(req.getWaypoints().isEmpty()); + } + + // ---- OSRM request URL ------------------------------------------------- + + @Test + void buildsOsrmUrlWithWaypointsInLonLatOrder() { + OsrmRouteService service = new OsrmRouteService("https://osrm.example.com/"); + RouteRequest req = new RouteRequest(new LatLng(38.8977, -77.0365), new LatLng(38.8894, -77.0352)) + .addWaypoint(new LatLng(38.8899, -77.0091)) + .setTravelMode(TravelMode.CYCLING) + .setAlternatives(true) + .setSteps(false); + + assertEquals("https://osrm.example.com/route/v1/cycling/" + + "-77.036500,38.897700;-77.009100,38.889900;-77.035200,38.889400" + + "?overview=full&geometries=polyline&steps=false&alternatives=true", + service.buildUrl(req)); + } + + @Test + void buildUrlFormatsCoordinatesWithoutScientificNotation() { + // A coordinate near the prime meridian is where Double.toString would + // emit "1.0E-5" and OSRM would reject the URL. + OsrmRouteService service = new OsrmRouteService(); + String url = service.buildUrl(new RouteRequest(new LatLng(0.00001, -0.00002), new LatLng(0, 0))); + assertTrue(url.indexOf("-0.000020,0.000010;0.000000,0.000000") > 0, url); + assertTrue(url.startsWith(OsrmRouteService.DEMO_BASE_URL), url); + } + + @Test + void baseUrlTrimsTrailingSlashesAndFallsBackToTheDemoServer() { + assertEquals("https://osrm.example.com", + new OsrmRouteService("https://osrm.example.com///").getBaseUrl()); + assertEquals(OsrmRouteService.DEMO_BASE_URL, new OsrmRouteService("").getBaseUrl()); + assertEquals(OsrmRouteService.DEMO_BASE_URL, new OsrmRouteService(null).getBaseUrl()); + assertTrue(new OsrmRouteService().isAvailable()); + assertEquals("osrm", new OsrmRouteService().getId()); + } + + // ---- OSRM response parsing ------------------------------------------- + + @Test + void parsesRouteGeometryDistanceAndDuration() throws IOException { + List routes = OsrmRouteService.parseResponse(sampleResponse()); + assertEquals(1, routes.size()); + + Route route = (Route) routes.get(0); + assertEquals(1234.5, route.getDistanceMeters(), 1e-6); + assertEquals(678.9, route.getDurationSeconds(), 1e-6); + assertEquals("Main Street", route.getSummary()); + assertEquals(3, route.getPoints().size()); + + LatLng first = (LatLng) route.getPoints().get(0); + assertEquals(38.5, first.getLatitude(), 1e-6); + assertEquals(-120.2, first.getLongitude(), 1e-6); + } + + @Test + void parsesLegsAndTurnByTurnSteps() throws IOException { + Route route = (Route) OsrmRouteService.parseResponse(sampleResponse()).get(0); + assertEquals(1, route.getLegs().size()); + + RouteLeg leg = (RouteLeg) route.getLegs().get(0); + assertEquals("Main Street", leg.getSummary()); + assertEquals(1234.5, leg.getDistanceMeters(), 1e-6); + assertEquals(3, leg.getSteps().size()); + + RouteStep depart = (RouteStep) leg.getSteps().get(0); + assertEquals("Head out on Main Street", depart.getInstruction()); + assertEquals("Main Street", depart.getRoadName()); + assertEquals(100.0, depart.getDistanceMeters(), 1e-6); + + RouteStep turn = (RouteStep) leg.getSteps().get(1); + assertEquals("Turn left onto Elm Street", turn.getInstruction()); + assertNotNull(turn.getStart()); + // OSRM reports [longitude, latitude]; the model must not swap them. + assertEquals(40.7, turn.getStart().getLatitude(), 1e-6); + assertEquals(-120.95, turn.getStart().getLongitude(), 1e-6); + + RouteStep arrive = (RouteStep) leg.getSteps().get(2); + assertEquals("Arrive at your destination", arrive.getInstruction()); + assertEquals("", arrive.getRoadName()); + } + + @Test + void roundaboutInstructionsNameTheExit() throws IOException { + // Without the exit number the instruction is useless at any roundabout + // with more than one way out. + assertEquals("At the roundabout take the 3rd exit onto Elm Street", + instructionFor("{\"type\":\"roundabout\",\"exit\":3}", "Elm Street")); + assertEquals("At the roundabout take the 1st exit onto Elm Street", + instructionFor("{\"type\":\"rotary\",\"exit\":1}", "Elm Street")); + assertEquals("At the roundabout take the 2nd exit", + instructionFor("{\"type\":\"roundabout\",\"exit\":2}", "")); + assertEquals("At the roundabout take the 11th exit", + instructionFor("{\"type\":\"roundabout\",\"exit\":11}", "")); + + // No exit reported -> the old wording rather than a bogus "0th". + assertEquals("Enter the roundabout and exit onto Elm Street", + instructionFor("{\"type\":\"roundabout\"}", "Elm Street")); + } + + @Test + void smallRoundaboutsKeepTheirTurnDirection() throws IOException { + // OSRM's "roundabout turn" is a small roundabout taken as an ordinary + // turn. It used to fall through to the generic wording, which read + // "Roundabout turn onto Main Street" and dropped the direction. + assertEquals("Turn left onto Main Street", + instructionFor("{\"type\":\"roundabout turn\",\"modifier\":\"left\"}", + "Main Street")); + assertEquals("Exit the roundabout onto Main Street", + instructionFor("{\"type\":\"exit roundabout\"}", "Main Street")); + } + + @Test + void legsAndStepsPresentButNotListsAreMalformed() { + // Absent is fine -- steps=false requests carry none. Present as + // something else is a bad response, and reading it as absent would + // hand back a half-populated route. + assertThrows(IOException.class, () -> OsrmRouteService.parseResponse( + "{\"code\":\"Ok\",\"routes\":[{\"geometry\":\"" + GEOMETRY + "\",\"legs\":7}]}")); + assertThrows(IOException.class, () -> OsrmRouteService.parseResponse( + "{\"code\":\"Ok\",\"routes\":[{\"geometry\":\"" + GEOMETRY + + "\",\"legs\":[{\"steps\":\"x\"}]}]}")); + } + + private static String instructionFor(String maneuver, String roadName) throws IOException { + String json = "{\"code\":\"Ok\",\"routes\":[{\"geometry\":\"" + GEOMETRY + "\"," + + "\"legs\":[{\"steps\":[{\"name\":\"" + roadName + "\",\"maneuver\":" + + maneuver + "}]}]}]}"; + Route route = (Route) OsrmRouteService.parseResponse(json).get(0); + RouteLeg leg = (RouteLeg) route.getLegs().get(0); + return ((RouteStep) leg.getSteps().get(0)).getInstruction(); + } + + @Test + void routeExposesDrawableGeometryAndBounds() throws IOException { + Route route = (Route) OsrmRouteService.parseResponse(sampleResponse()).get(0); + + Polyline pl = route.toPolyline(); + assertEquals(3, pl.getPoints().size()); + // Each call yields an independent polyline so styling one is isolated. + assertNotSame(pl, route.toPolyline()); + + MapBounds bounds = route.getBounds(); + assertNotNull(bounds); + assertEquals(38.5, bounds.getSouthWest().getLatitude(), 1e-6); + assertEquals(-126.453, bounds.getSouthWest().getLongitude(), 1e-6); + assertEquals(43.252, bounds.getNorthEast().getLatitude(), 1e-6); + assertEquals(-120.2, bounds.getNorthEast().getLongitude(), 1e-6); + } + + @Test + void emptyRouteHasNoBoundsAndNoGeometry() { + Route route = new Route(null, null, 0, 0, null); + assertNull(route.getBounds()); + assertTrue(route.getPoints().isEmpty()); + assertTrue(route.getLegs().isEmpty()); + assertEquals("", route.getSummary()); + assertTrue(route.toPolyline().getPoints().isEmpty()); + } + + @Test + void parsesAlternativeRoutes() throws IOException { + String json = "{\"code\":\"Ok\",\"routes\":[" + + "{\"geometry\":\"" + GEOMETRY + "\",\"distance\":100,\"duration\":10,\"legs\":[]}," + + "{\"geometry\":\"" + GEOMETRY + "\",\"distance\":200,\"duration\":20,\"legs\":[]}]}"; + List routes = OsrmRouteService.parseResponse(json); + assertEquals(2, routes.size()); + assertEquals(100.0, ((Route) routes.get(0)).getDistanceMeters(), 1e-6); + assertEquals(200.0, ((Route) routes.get(1)).getDistanceMeters(), 1e-6); + } + + @Test + void reportsServiceLevelRoutingFailures() { + IOException noRoute = assertThrows(IOException.class, + () -> OsrmRouteService.parseResponse("{\"code\":\"NoRoute\"}")); + assertEquals("No route connects those points for the selected travel mode", + noRoute.getMessage()); + + IOException noSegment = assertThrows(IOException.class, + () -> OsrmRouteService.parseResponse("{\"code\":\"NoSegment\"}")); + assertEquals("One of the points is too far from any road", noSegment.getMessage()); + + // A message from the service wins over our generic phrasing. + IOException withMessage = assertThrows(IOException.class, + () -> OsrmRouteService.parseResponse("{\"code\":\"InvalidValue\",\"message\":\"bad radius\"}")); + assertEquals("bad radius", withMessage.getMessage()); + } + + @Test + void aRouteWithoutGeometryIsRejected() { + // The request always asks for overview=full, so a route with nothing + // decodable is a bad answer. Accepting it would draw an empty polyline + // and report success. + assertThrows(IOException.class, + () -> OsrmRouteService.parseResponse("{\"code\":\"Ok\",\"routes\":[{}]}")); + assertThrows(IOException.class, () -> OsrmRouteService.parseResponse( + "{\"code\":\"Ok\",\"routes\":[{\"geometry\":\"\",\"distance\":1,\"duration\":1}]}")); + } + + @Test + void reportsMalformedAndEmptyResponses() { + assertThrows(IOException.class, () -> OsrmRouteService.parseResponse(null)); + assertThrows(IOException.class, () -> OsrmRouteService.parseResponse("")); + assertThrows(IOException.class, () -> OsrmRouteService.parseResponse("{\"code\":\"Ok\"}")); + assertThrows(IOException.class, + () -> OsrmRouteService.parseResponse("{\"code\":\"Ok\",\"routes\":[]}")); + } + + @Test + void nonObjectRouteLegAndStepEntriesRaiseIoException() { + // parseResponse is public, so a caller feeding it a body from its own + // transport must be able to rely on the documented IOException. A + // non-object entry used to escape as an unchecked NullPointerException + // or ClassCastException straight through the catch. + assertThrows(IOException.class, + () -> OsrmRouteService.parseResponse("{\"code\":\"Ok\",\"routes\":[null]}")); + assertThrows(IOException.class, + () -> OsrmRouteService.parseResponse("{\"code\":\"Ok\",\"routes\":[7]}")); + assertThrows(IOException.class, () -> OsrmRouteService.parseResponse( + "{\"code\":\"Ok\",\"routes\":[{\"geometry\":\"" + GEOMETRY + "\",\"legs\":[null]}]}")); + assertThrows(IOException.class, () -> OsrmRouteService.parseResponse( + "{\"code\":\"Ok\",\"routes\":[{\"geometry\":\"" + GEOMETRY + + "\",\"legs\":[{\"steps\":[\"turn left\"]}]}]}")); + } + + // ---- Routing facade --------------------------------------------------- + + @Test + void routingDefaultsToTheKeylessOsrmService() { + assertInstanceOf(OsrmRouteService.class, Routing.getService()); + } + + @Test + void routingDelegatesToTheInstalledService() { + RecordingService service = new RecordingService(); + Routing.setService(service); + assertSame(service, Routing.getService()); + + LatLng origin = new LatLng(38.8977, -77.0365); + LatLng destination = new LatLng(38.8894, -77.0352); + Routing.findRoute(origin, destination, service.callback); + + assertNotNull(service.lastRequest); + assertEquals(origin, service.lastRequest.getOrigin()); + assertEquals(destination, service.lastRequest.getDestination()); + assertEquals(TravelMode.DRIVING, service.lastRequest.getTravelMode()); + } + + @Test + void aNullCallbackIsRejectedRatherThanSilentlyIgnored() { + // An asynchronous call with nowhere to report its result is a mistake; + // returning quietly just leaves the caller waiting on nothing. + Routing.setService(new RecordingService()); + assertThrows(IllegalArgumentException.class, + () -> Routing.findRoute(new LatLng(1, 2), new LatLng(3, 4), null)); + assertThrows(IllegalArgumentException.class, () -> new OsrmRouteService() + .findRoutes(new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)), null)); + } + + @Test + void anUnavailableServiceFailsTheRequestInsteadOfBeingCalled() { + // isAvailable() is the SPI's fail-fast hook; the facade has to honor it + // or every caller ends up repeating the check. + RecordingService service = new RecordingService(); + service.available = false; + Routing.setService(service); + + Routing.findRoute(new LatLng(1, 2), new LatLng(3, 4), service.callback); + assertNull(service.lastRequest, "an unavailable service must not be asked to route"); + } + + @Test + void nonFiniteCoordinatesAreRejectedRatherThanRoutedFromNullIsland() { + // LatLng lets NaN through -- its range clamps compare against NaN, and + // those comparisons are always false -- and Math.round(NaN) is 0, so + // the URL would have said 0.000000 and routed from off Africa. + CountingCallback counts = new CountingCallback(); + OsrmRouteService service = new OsrmRouteService(); + + service.findRoutes(new RouteRequest(new LatLng(Double.NaN, Double.NaN), + new LatLng(38.8894, -77.0352)), counts); + service.findRoutes(new RouteRequest(new LatLng(38.8977, -77.0365), + new LatLng(0, Double.NaN)), counts); + service.findRoutes(new RouteRequest(new LatLng(38.8977, -77.0365), + new LatLng(38.8894, -77.0352)).addWaypoint(new LatLng(Double.NaN, 0)), counts); + + assertEquals(0, counts.successes); + assertEquals(3, counts.failures, "each unusable coordinate must fail its request"); + + // Infinities never survive LatLng to reach the check: an infinite + // latitude trips the range clamp (Infinity > 90 is true) and lands on + // 90, while an infinite longitude degenerates to NaN in the wrap + // arithmetic. NaN is the only non-finite value that gets through. + assertEquals(90.0, new LatLng(Double.POSITIVE_INFINITY, 0).getLatitude(), 1e-9); + assertTrue(Double.isNaN(new LatLng(0, Double.POSITIVE_INFINITY).getLongitude())); + } + + @Test + void aServiceWhoseReadinessProbeThrowsStillProducesOneCallback() { + // isAvailable() and getId() are third-party code too; a service that + // throws while checking its own configuration must not escape the + // facade's exactly-once promise. + Routing.setService(new ThrowingProbeService()); + CountingCallback counts = new CountingCallback(); + Routing.findRoute(new LatLng(1, 2), new LatLng(3, 4), counts); + assertEquals(1, counts.failures); + assertEquals(0, counts.successes); + } + + @Test + void aServiceThatThrowsStillProducesExactlyOneCallback() { + // findRoute promises the app one answer. A third-party service that + // throws instead of calling back must not turn that into silence. + Routing.setService(new ThrowingService(false)); + CountingCallback counts = new CountingCallback(); + Routing.findRoute(new LatLng(1, 2), new LatLng(3, 4), counts); + assertEquals(1, counts.failures, "a throwing service must still be reported"); + assertEquals(0, counts.successes); + } + + @Test + void aServiceThatReportsThenThrowsIsNotDeliveredTwice() { + // The latch matters as much as the guard: a service that calls back + // and then throws would otherwise produce two answers. + Routing.setService(new ThrowingService(true)); + CountingCallback counts = new CountingCallback(); + Routing.findRoute(new LatLng(1, 2), new LatLng(3, 4), counts); + assertEquals(1, counts.failures + counts.successes, "exactly one answer"); + } + + @Test + void settingANullServiceRestoresTheDefault() { + Routing.setService(new RecordingService()); + Routing.setService(null); + assertInstanceOf(OsrmRouteService.class, Routing.getService()); + } + + // ---- Fixtures --------------------------------------------------------- + + private static String sampleResponse() { + return "{\"code\":\"Ok\",\"routes\":[{" + + "\"geometry\":\"" + GEOMETRY + "\"," + + "\"distance\":1234.5,\"duration\":678.9," + + "\"legs\":[{\"summary\":\"Main Street\",\"distance\":1234.5,\"duration\":678.9," + + "\"steps\":[" + + "{\"name\":\"Main Street\",\"distance\":100.0,\"duration\":20.0," + + "\"geometry\":\"" + GEOMETRY + "\"," + + "\"maneuver\":{\"type\":\"depart\",\"location\":[-120.2,38.5]}}," + + "{\"name\":\"Elm Street\",\"distance\":900.0,\"duration\":300.0," + + "\"maneuver\":{\"type\":\"turn\",\"modifier\":\"left\",\"location\":[-120.95,40.7]}}," + + "{\"name\":\"\",\"distance\":0,\"duration\":0," + + "\"maneuver\":{\"type\":\"arrive\",\"location\":[-126.453,43.252]}}" + + "]}]}]}"; + } + + /** Counts how many times each outcome reached the application. */ + private static final class CountingCallback implements RouteCallback { + + private int successes; + private int failures; + + @Override + public void routesFound(List routes) { + successes++; + } + + @Override + public void routeFailed(String message, Throwable error) { + failures++; + } + } + + /** A service that blows up while being asked whether it is ready. */ + private static final class ThrowingProbeService implements RouteService { + + @Override + public String getId() { + return "throwing-probe"; + } + + @Override + public boolean isAvailable() { + throw new IllegalStateException("configuration check exploded"); + } + + @Override + public void findRoutes(RouteRequest request, RouteCallback cb) { + throw new IllegalStateException("should never be reached"); + } + } + + /** A service that breaks the SPI contract, optionally after reporting. */ + private static final class ThrowingService implements RouteService { + + private final boolean reportFirst; + + ThrowingService(boolean reportFirst) { + this.reportFirst = reportFirst; + } + + @Override + public String getId() { + return "throwing"; + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public void findRoutes(RouteRequest request, RouteCallback cb) { + if (reportFirst) { + cb.routeFailed("reported before throwing", null); + } + throw new IllegalStateException("this service is broken"); + } + } + + /** A stand-in service that records the request instead of hitting the network. */ + private static final class RecordingService implements RouteService { + + private RouteRequest lastRequest; + private boolean available = true; + private final RouteCallback callback = new RouteCallback() { + @Override + public void routesFound(List routes) { + } + + @Override + public void routeFailed(String message, Throwable error) { + } + }; + + @Override + public String getId() { + return "recording"; + } + + @Override + public boolean isAvailable() { + return available; + } + + @Override + public void findRoutes(RouteRequest request, RouteCallback cb) { + lastRequest = request; + cb.routesFound(new ArrayList()); + } + } +}