diff --git a/core/util-jvm/src/main/java/org/signal/core/util/Linkifier.kt b/core/util-jvm/src/main/java/org/signal/core/util/Linkifier.kt index cca9e7310d3..b819dbb26d3 100644 --- a/core/util-jvm/src/main/java/org/signal/core/util/Linkifier.kt +++ b/core/util-jvm/src/main/java/org/signal/core/util/Linkifier.kt @@ -68,7 +68,18 @@ object Linkifier { ) /** - * Finds all web URLs in [text], in left-to-right order. + * Matches a `geo:` URI per RFC 5870 plus Google's `?q=`/`?z=` query extension. Coordinate shape + * is permissive here — lat/lon ranges are validated separately by [hasValidGeoCoordinates]. + */ + private val GEO_URI_PATTERN: Pattern = Pattern.compile( + "(?i)geo:" + + "-?\\d{1,3}(?:\\.\\d+)?,-?\\d{1,3}(?:\\.\\d+)?" + + "(?:,-?\\d+(?:\\.\\d+)?)?" + + "(?:[;?]" + URL_CHAR + "*)?" + ) + + /** + * Finds all web URLs and `geo:` URIs in [text], in left-to-right order. */ @JvmStatic fun findLinks(text: CharSequence): List { @@ -76,11 +87,12 @@ object Linkifier { return emptyList() } - val matcher = WEB_URL_PATTERN.matcher(text) val out = ArrayList() - while (matcher.find()) { - val start = matcher.start() - val rawEnd = matcher.end() + + val webMatcher = WEB_URL_PATTERN.matcher(text) + while (webMatcher.find()) { + val start = webMatcher.start() + val rawEnd = webMatcher.end() if (!isAtLeadingBoundary(text, start)) { continue @@ -105,6 +117,36 @@ object Linkifier { val normalized = if (raw.contains("://")) raw else "http://$raw" out.add(DetectedLink(start, end, normalized)) } + + val geoMatcher = GEO_URI_PATTERN.matcher(text) + while (geoMatcher.find()) { + val start = geoMatcher.start() + val rawEnd = geoMatcher.end() + + if (!isAtLeadingBoundary(text, start)) { + continue + } + + val end = trimTrailingNonUrlChars(text, start, rawEnd) + if (end <= start) { + continue + } + + if (end < text.length && isFormatOrZeroWidth(text[end])) { + continue + } + + val raw = text.subSequence(start, end).toString() + if (!hasValidGeoCoordinates(raw)) { + continue + } + + out.add(DetectedLink(start, end, raw)) + } + + if (out.size > 1) { + out.sortBy { it.start } + } return out } @@ -210,6 +252,20 @@ object Linkifier { return tld.lowercase() in TOP_LEVEL_DOMAINS } + /** + * Checks that the lat/lon embedded in a geo URI fall within valid Earth ranges. The regex is + * permissive so out-of-range coordinates (e.g. lat 91) still need to be rejected here. + */ + private fun hasValidGeoCoordinates(geoUri: String): Boolean { + val body = geoUri.substring("geo:".length) + val coordEnd = body.indexOfAny(charArrayOf(';', '?')).let { if (it < 0) body.length else it } + val parts = body.substring(0, coordEnd).split(',') + if (parts.size < 2 || parts.size > 3) return false + val lat = parts[0].toDoubleOrNull() ?: return false + val lon = parts[1].toDoubleOrNull() ?: return false + return lat in -90.0..90.0 && lon in -180.0..180.0 + } + /** Returns a set containing every entry in [unicode] plus its punycode form (where applicable). */ private fun withPunycode(unicode: Set): Set { val out: MutableSet = HashSet(unicode.size * 2) diff --git a/core/util-jvm/src/test/java/org/signal/core/util/LinkifierTest.kt b/core/util-jvm/src/test/java/org/signal/core/util/LinkifierTest.kt index 318b5d041a8..cbae18c102e 100644 --- a/core/util-jvm/src/test/java/org/signal/core/util/LinkifierTest.kt +++ b/core/util-jvm/src/test/java/org/signal/core/util/LinkifierTest.kt @@ -66,6 +66,7 @@ class LinkifierTest(private val case: Case) { companion object { private fun web(spanText: String, url: String = spanText) = Expected(spanText, url) + private fun geo(spanText: String) = Expected(spanText, url = spanText) @Parameterized.Parameters(name = "{0}") @JvmStatic @@ -177,7 +178,40 @@ class LinkifierTest(private val case: Case) { Case("leading bidi override rejects url", "‬moc.diordna.com next", emptyList()), Case("trailing bidi override rejects url", "moc.diordna.com‭ next", emptyList()), Case("internal bidi override rejects both halves", "moc.diordna.com‮moc.diordna.com", emptyList()), - Case("zero-width space adjacent to url rejects it", "see signal.org​ next", emptyList()) + Case("zero-width space adjacent to url rejects it", "see signal.org​ next", emptyList()), + + // ----- geo URIs ----- + Case("geo uri with basic coords", "geo:48.2010,16.3695", listOf(geo("geo:48.2010,16.3695"))), + Case("geo uri with altitude", "geo:48.2010,16.3695,123", listOf(geo("geo:48.2010,16.3695,123"))), + Case("geo uri with uncertainty param", "geo:48.2010,16.3695;u=40", listOf(geo("geo:48.2010,16.3695;u=40"))), + Case("geo uri with google query and label", "geo:0,0?q=37.786971,-122.399677(Treasure%20Island)", listOf(geo("geo:0,0?q=37.786971,-122.399677(Treasure%20Island)"))), + Case("geo uri with google search query", "geo:0,0?q=Eiffel+Tower", listOf(geo("geo:0,0?q=Eiffel+Tower"))), + Case("geo uri with negative coords", "geo:-33.86,151.21", listOf(geo("geo:-33.86,151.21"))), + Case("geo uri at origin", "geo:0,0", listOf(geo("geo:0,0"))), + Case("geo uri scheme is case insensitive", "GEO:48.2,16.3", listOf(geo("GEO:48.2,16.3"))), + + // ----- geo URI in sentence context ----- + Case("geo uri followed by sentence period", "Meet at geo:48.2,16.3.", listOf(geo("geo:48.2,16.3"))), + Case("geo uri followed by sentence comma", "From geo:48.2,16.3, walk north", listOf(geo("geo:48.2,16.3"))), + Case("geo uri inside parens is detected without them", "(geo:48.2,16.3)", listOf(geo("geo:48.2,16.3"))), + Case("balanced parens inside geo query are preserved", "place geo:0,0?q=37.78,-122.4(Treasure%20Island) end", listOf(geo("geo:0,0?q=37.78,-122.4(Treasure%20Island)"))), + Case("unmatched close paren after geo query is trimmed", "(see geo:0,0?q=foo)", listOf(geo("geo:0,0?q=foo"))), + Case("non-numeric altitude falls back to coord-only match", "geo:48,16,foo", listOf(geo("geo:48,16"))), + + // ----- geo URI false-positive guards ----- + Case("geo with lat over 90 is rejected", "geo:91.0,0", emptyList()), + Case("geo with lon over 180 is rejected", "geo:0,181.0", emptyList()), + Case("geo with lat below -90 is rejected", "geo:-91,0", emptyList()), + Case("geo with non-numeric coords is not matched", "geo:foo,bar", emptyList()), + Case("geo glued to a leading letter is rejected", "xgeo:48.2,16.3", emptyList()), + Case("geo missing lon is not matched", "geo:48.2", emptyList()), + Case("bare geo scheme is not matched", "geo:", emptyList()), + Case("whitespace between geo coords breaks the uri", "geo:48.2, 16.3", emptyList()), + + // ----- geo + web mixed ----- + Case("geo uri then web url ordered left-to-right", "see geo:48.2,16.3 and https://signal.org", listOf(geo("geo:48.2,16.3"), web("https://signal.org"))), + Case("web url then geo uri ordered left-to-right", "https://signal.org then geo:48.2,16.3", listOf(web("https://signal.org"), geo("geo:48.2,16.3"))), + Case("two geo uris in the same string", "from geo:48.2,16.3 to geo:51.5,-0.1", listOf(geo("geo:48.2,16.3"), geo("geo:51.5,-0.1"))) ).map { arrayOf(it) } } }