diff --git a/app/src/main/java/org/surfsite/gexporter/SharedLocation.java b/app/src/main/java/org/surfsite/gexporter/SharedLocation.java index 86a6620..284e665 100644 --- a/app/src/main/java/org/surfsite/gexporter/SharedLocation.java +++ b/app/src/main/java/org/surfsite/gexporter/SharedLocation.java @@ -15,8 +15,10 @@ import java.net.URL; import java.net.URLDecoder; import java.net.URLEncoder; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.regex.Matcher; @@ -45,6 +47,11 @@ public class SharedLocation { private static final Pattern GOOGLE_PLACE = Pattern.compile("!3d" + NUM + "!4d" + NUM); private static final Pattern GOOGLE_VIEWPORT = Pattern.compile("@" + NUM + "," + NUM); private static final Pattern GOOGLE_PLACE_NAME = Pattern.compile("/maps/place/([^/@?]+)"); + // Directions (/maps/dir////@viewport/data=...): + // the data part lists the position of each named stop, as !1d!2d + // or (Google Maps app, 2026) as !3d!4d. + private static final Pattern GOOGLE_DIR_STOP = Pattern.compile("!1d" + NUM + "!2d" + NUM + "|!3d" + NUM + "!4d" + NUM); + private static final Pattern GOOGLE_DIR_QUERY = Pattern.compile("[?&](?:destination|daddr)=" + NUM + "(?:,|%2C)\\+?" + NUM, Pattern.CASE_INSENSITIVE); private static final Pattern QUERY_COORDS = Pattern.compile("[?&](?:q|ll|query|destination|daddr|center|sll)=" + NUM + "(?:,|%2C)\\+?" + NUM, Pattern.CASE_INSENSITIVE); // In the EU, Google redirects every request without this cookie to the // consent dialog, so following a place URL would loop through it. @@ -110,7 +117,9 @@ public static SharedLocation parse(String text) { } } - Matcher c = PLAIN_COORDS.matcher(text); + // Coordinates inside a link are for the link parsers above: a + // directions link's viewport is not a place. + Matcher c = PLAIN_COORDS.matcher(URL_PATTERN.matcher(text).replaceAll(" ")); while (c.find()) { SharedLocation loc = fromStrings(name, c.group(1), c.group(2)); if (loc != null) { @@ -329,6 +338,9 @@ private static SharedLocation parseGeoUri(String uri, String fallbackName) { } private static SharedLocation parseGoogleUrl(String url, String fallbackName) { + if (googleDirStops(url) != null) { + return parseGoogleDirections(url, fallbackName); + } String placeName = googlePlaceName(url); String name = placeName != null ? placeName : fallbackName; @@ -344,6 +356,63 @@ private static SharedLocation parseGoogleUrl(String url, String fallbackName) { return null; } + /** + * The destination of a directions link, which is its last stop. The + * viewport (@lat,lon) is never used: it shows the whole route, and its + * center is somewhere between start and destination. + */ + private static SharedLocation parseGoogleDirections(String url, String fallbackName) { + String placeName = googlePlaceName(url); + String name = placeName != null ? placeName : fallbackName; + + Matcher m = GOOGLE_DIR_QUERY.matcher(url); + if (m.find()) { + return fromStrings(name, m.group(1), m.group(2)); + } + List stops = googleDirStops(url); + if (stops.isEmpty()) { + return null; + } + // a destination given as coordinates has no entry in the data part + m = PLAIN_COORDS.matcher(stops.get(stops.size() - 1)); + if (m.matches()) { + return fromStrings(fallbackName, m.group(1), m.group(2)); + } + m = GOOGLE_DIR_STOP.matcher(url); + String lat = null; + String lon = null; + while (m.find()) { + if (m.group(1) != null) { + lon = m.group(1); + lat = m.group(2); + } else { + lat = m.group(3); + lon = m.group(4); + } + } + return lat != null ? fromStrings(name, lat, lon) : null; + } + + /** The stops of a /maps/dir/ link (start first, destination last), or null. */ + private static List googleDirStops(String url) { + int i = url.indexOf("/maps/dir/"); + if (i < 0) { + return null; + } + String rest = url.substring(i + "/maps/dir/".length()).split("[?#]", 2)[0]; + List stops = new ArrayList<>(); + for (String segment : rest.split("/")) { + if (segment.startsWith("@") || segment.startsWith("data=")) { + break; + } + String stop = urlDecode(segment).trim(); + if (!stop.isEmpty()) { + stops.add(stop); + } + } + return stops; + } + private static boolean isGoogle(String url) { String host; try { @@ -355,10 +424,22 @@ private static boolean isGoogle(String url) { || host.matches("(?:.+\\.)?google\\.[a-z.]+"); } - /** ",
" from /maps/place/ or ?q=, or null. */ + /** + * ",
" from /maps/place/, ?q= or the + * destination of a directions link, or null. + */ private static String googlePlaceText(String url) { - Matcher m = GOOGLE_PLACE_NAME.matcher(url); - String text = m.find() ? urlDecode(m.group(1)) : queryParam(url, "q"); + String text; + List stops = googleDirStops(url); + if (stops != null) { + text = queryParam(url, "destination"); + if (text == null) { + text = stops.isEmpty() ? null : stops.get(stops.size() - 1); + } + } else { + Matcher m = GOOGLE_PLACE_NAME.matcher(url); + text = m.find() ? urlDecode(m.group(1)) : queryParam(url, "q"); + } if (text == null) { return null; } diff --git a/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java b/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java index 441d741..bd4bbf0 100644 --- a/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java +++ b/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java @@ -147,6 +147,51 @@ public void organicMapsPageIsNoLocation() { assertNull(SharedLocation.parse("https://omaps.app/get")); } + // Shape of a Google Maps "Share directions" link once its short link is + // resolved: start, destination, the route's viewport, and in data= the + // position of each named stop as !1d!2d. + private static final String DIRECTIONS = "https://www.google.com/maps/dir/M%C3%BCnchen/Zugspitze/" + + "@47.7869,11.0712,9z/data=!3m1!4b1!4m13!4m12" + + "!1m5!1m1!1s0x479e75f9a38c5fd9:0x10cb84a7db1987d!2m2!1d11.5819806!2d48.1351253" + + "!1m5!1m1!1s0x479d0b6a6d6e2a0b:0x2e8d7b9f2a3c!2m2!1d10.9863!2d47.4211"; + + @Test + public void directionsGoToTheDestinationNotTheViewport() { + assertLoc(SharedLocation.parse(DIRECTIONS), "Zugspitze", 47.4211, 10.9863); + } + + @Test + public void directionsFromTheGoogleMapsApp() { + // Shape of a 2026 "Share directions" from the Google Maps app, once + // resolved: the start is the current position, the destination is + // named and its position is in data= as !3d!4d. + String url = "https://www.google.com/maps/dir/48.1,11.5/Some+Shop+GmbH/data=!4m12!4m11!1m1!4e1" + + "!1m5!1m4!1s0x0:0x0!8m2!3d47.4211!4d10.9863!2m1!11b1!3e2?utm_source=mstt_0"; + assertLoc(SharedLocation.parse(url), "Some Shop GmbH", 47.4211, 10.9863); + } + + @Test + public void directionsWithCoordinateStops() { + assertLoc(SharedLocation.parse("https://www.google.com/maps/dir/48.1351253,11.5819806/47.4211,10.9863/@47.8,11.2,9z"), + SharedLocation.DEFAULT_NAME, 47.4211, 10.9863); + } + + @Test + public void directionsApiLink() { + assertLoc(SharedLocation.parse("https://www.google.com/maps/dir/?api=1&destination=47.4211,10.9863"), + SharedLocation.DEFAULT_NAME, 47.4211, 10.9863); + } + + @Test + public void directionsWithoutPositionsAreGeocoded() throws Exception { + String url = "https://www.google.com/maps/dir/M%C3%BCnchen/Zugspitze/@47.7869,11.0712,9z"; + assertNull(SharedLocation.parse(url)); + FakeFetcher f = new FakeFetcher(); + f.redirects.put("https://maps.app.goo.gl/dir123", url); + f.bodies.put(nominatim("Zugspitze"), "[{\"lat\":\"47.4211\",\"lon\":\"10.9863\"}]"); + assertLoc(SharedLocation.resolve("https://maps.app.goo.gl/dir123", f), "Zugspitze", 47.4211, 10.9863); + } + @Test public void unknownLinkNeedsResolving() { assertNull(SharedLocation.parse("Stephansdom\nhttps://maps.app.goo.gl/abc123"));