From 86b2819ba6f77f478bedd94d871c18efd97f9b77 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 2 Sep 2026 21:18:50 +0000 Subject: [PATCH] Allow apostrophes within linkified URL paths. --- .../java/org/signal/core/util/Linkifier.kt | 20 ++++++++++++++++--- .../org/signal/core/util/LinkifierTest.kt | 11 ++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) 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 b4a44a0ec9..a1c17d455d 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 @@ -27,13 +27,26 @@ object Linkifier { /** Closing brackets that are trimmed only if the URL has no matching opener inside */ private val CLOSING_BRACKETS = mapOf(')' to '(', ']' to '[', '}' to '{') + /** + * Characters that always terminate a URL: whitespace (including the unicode variants), bidi/format + * controls, and punctuation that is only ever used to quote or wrap a URL. + */ + private const val URL_TERMINATOR = "\\s\\u0085\\u00A0\\u1680\\u2000-\\u200D\\u2028\\u2029\\u202A-\\u202F\\u205F\\u2066-\\u2069\\u3000\\uFEFF<>\"`|\\\\" + /** * Characters we treat as definitely-not-part-of-a-URL when extending past the host. Commas and * semicolons are allowed inside a URL, but not when they are acting as a separator before another * obvious URL. */ - private const val URL_CHAR = - "(?:[^\\s\\u0085\\u00A0\\u1680\\u2000-\\u200D\\u2028\\u2029\\u202A-\\u202F\\u205F\\u2066-\\u2069\\u3000\\uFEFF<>\"'`,;|\\\\]|[,;](?!https?://|www\\.))" + private const val URL_CHAR = "(?:[^$URL_TERMINATOR,;]|[,;](?!https?://|www\\.))" + + /** + * Like [URL_CHAR], but for the authority (host/port/userinfo) portion of a URL, so it stops at the + * first path/query/fragment delimiter. Apostrophes are excluded here so that a possessive like + * `https://signal.org's blog` doesn't drag the `'s` into the host, while still allowing them + * within a path (see [URL_CHAR]). + */ + private const val AUTHORITY_CHAR = "(?:[^$URL_TERMINATOR,;'/?#]|[,;](?!https?://|www\\.))" /** * A single domain label: letter/digit, optional letter/digit/hyphen body. Used for intermediate @@ -56,7 +69,8 @@ object Linkifier { "(?i)" + "(?:" + // Variant 1: explicit scheme (http or https). User intent is unambiguous; no TLD check. - "https?://" + URL_CHAR + "+" + + "https?://" + AUTHORITY_CHAR + "+" + + "(?:[/?#]" + URL_CHAR + "*)?" + "|" + // Variant 2: starts with www. — also unambiguous; no TLD check. "www\\." + DOMAIN_LABEL + "(?:\\." + DOMAIN_LABEL + ")+" + 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 c6c0d29c62..bbc2e3871d 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 @@ -108,6 +108,17 @@ class LinkifierTest(private val case: Case) { Case("semicolon in url path is preserved", "https://example.com/pricewatch/compare/2290176;2126182/", listOf(web("https://example.com/pricewatch/compare/2290176;2126182/"))), Case("semicolon in url query is preserved", "Go to https://example.com/search?a=1;b=2", listOf(web("https://example.com/search?a=1;b=2"))), + // ----- apostrophes ----- + Case("apostrophe in url path is preserved", "https://en.wikipedia.org/wiki/Occam's_razor", listOf(web("https://en.wikipedia.org/wiki/Occam's_razor"))), + Case("apostrophe in url path is preserved mid-sentence", "see https://en.wikipedia.org/wiki/Occam's_razor for more", listOf(web("https://en.wikipedia.org/wiki/Occam's_razor"))), + Case("apostrophe in bare domain path is preserved", "en.wikipedia.org/wiki/Occam's_razor", listOf(web("en.wikipedia.org/wiki/Occam's_razor", url = "http://en.wikipedia.org/wiki/Occam's_razor"))), + Case("apostrophe in url query is preserved", "https://example.com/search?q=Occam's", listOf(web("https://example.com/search?q=Occam's"))), + Case("trailing apostrophe is trimmed", "https://example.com/foo' end", listOf(web("https://example.com/foo"))), + Case("single-quoted url does not include the quotes", "'https://signal.org'", listOf(web("https://signal.org"))), + Case("possessive after schemed host is not part of the url", "https://signal.org's blog is good", listOf(web("https://signal.org"))), + Case("possessive after bare domain is not part of the url", "signal.org's blog is good", listOf(web("signal.org", url = "http://signal.org"))), + Case("possessive after www domain is not part of the url", "www.signal.org's blog is good", listOf(web("www.signal.org", url = "http://www.signal.org"))), + // ----- bracket / paren handling ----- Case("trailing closing paren without opener is trimmed", "(see https://signal.org)", listOf(web("https://signal.org"))), Case("trailing closing paren with matching opener inside is preserved", "https://en.wikipedia.org/wiki/Foo_(bar) and more", listOf(web("https://en.wikipedia.org/wiki/Foo_(bar)"))),