From de0dc96fc348c5104980a8bdf84a293ee2af7038 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Wed, 21 Oct 2026 20:03:57 +0200 Subject: [PATCH 1/2] fix: a shared Google Maps route navigates to its destination Sharing directions from Google Maps sent the center of the route's map view (or, for coordinate stops, the start) to the watch. A /maps/dir/ link now yields its last stop: from ?destination=, a coordinate stop, the stop positions in the data part, or by geocoding the destination's name. Coordinates inside a link are no longer picked up as a plain-text place. Refs #12 --- .../surfsite/gexporter/SharedLocation.java | 83 ++++++++++++++++++- .../gexporter/TestSharedLocation.java | 35 ++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/surfsite/gexporter/SharedLocation.java b/app/src/main/java/org/surfsite/gexporter/SharedLocation.java index 86a6620..2eeff97 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,10 @@ 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. + private static final Pattern GOOGLE_DIR_STOP = Pattern.compile("!1d" + NUM + "!2d" + 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 +116,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 +337,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 +355,58 @@ 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()) { + lon = m.group(1); + lat = m.group(2); + } + 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 +418,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..929580d 100644 --- a/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java +++ b/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java @@ -147,6 +147,41 @@ 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 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")); From ed0f8494871d25e4dc21a2a04567fe4544ddb8f1 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Wed, 21 Oct 2026 20:18:43 +0200 Subject: [PATCH 2/2] fix: read the destination of routes shared from the Google Maps app The app's directions links give the stop positions as !3d!4d, not !1d!2d. Without them the destination's name was geocoded, which fails for business names unknown to OpenStreetMap. Tested on a phone: a route shared from Google Maps arrives on the watch with its destination. --- .../org/surfsite/gexporter/SharedLocation.java | 14 ++++++++++---- .../org/surfsite/gexporter/TestSharedLocation.java | 10 ++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/surfsite/gexporter/SharedLocation.java b/app/src/main/java/org/surfsite/gexporter/SharedLocation.java index 2eeff97..284e665 100644 --- a/app/src/main/java/org/surfsite/gexporter/SharedLocation.java +++ b/app/src/main/java/org/surfsite/gexporter/SharedLocation.java @@ -48,8 +48,9 @@ public class SharedLocation { 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. - private static final Pattern GOOGLE_DIR_STOP = Pattern.compile("!1d" + NUM + "!2d" + NUM); + // 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 @@ -381,8 +382,13 @@ private static SharedLocation parseGoogleDirections(String url, String fallbackN String lat = null; String lon = null; while (m.find()) { - lon = m.group(1); - lat = m.group(2); + 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; } diff --git a/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java b/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java index 929580d..bd4bbf0 100644 --- a/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java +++ b/app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java @@ -160,6 +160,16 @@ 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"),