Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions app/src/main/java/org/surfsite/gexporter/SharedLocation.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/<start>/<stops>/<destination>/@viewport/data=...):
// the data part lists the position of each named stop, as !1d<lon>!2d<lat>
// or (Google Maps app, 2026) as !3d<lat>!4d<lon>.
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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand All @@ -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<String> 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<String> 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<String> 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 {
Expand All @@ -355,10 +424,22 @@ private static boolean isGoogle(String url) {
|| host.matches("(?:.+\\.)?google\\.[a-z.]+");
}

/** "<name>, <address>" from /maps/place/<text> or ?q=<text>, or null. */
/**
* "<name>, <address>" from /maps/place/<text>, ?q=<text> 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<String> 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;
}
Expand Down
45 changes: 45 additions & 0 deletions app/src/test/java/org/surfsite/gexporter/TestSharedLocation.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<lon>!2d<lat>.
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<lat>!4d<lon>.
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"));
Expand Down
Loading