From e2b6e9feb919c51bfc4c146426a2f5b1d3abc206 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Wed, 2 Apr 2025 16:06:17 +0200 Subject: [PATCH] support gemini URI scheme; start URIs on punct --- src/main/java/de/gultsch/common/MiniUri.java | 110 ++++++++++++++++++ .../utils => de/gultsch/common}/Patterns.java | 4 +- .../siacs/conversations/entities/Message.java | 2 +- .../ui/ConversationFragment.java | 2 +- .../conversations/ui/util/MyLinkify.java | 2 +- .../siacs/conversations/utils/GeoHelper.java | 1 + .../java/eu/siacs/conversations/utils/IP.java | 1 + .../conversations/xmpp/XmppConnection.java | 3 +- .../java/de/gultsch/common/MiniUriTest.java | 62 ++++++++++ .../java/de/gultsch/common/PatternTest.java | 94 +++++++++++++++ 10 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 src/main/java/de/gultsch/common/MiniUri.java rename src/main/java/{eu/siacs/conversations/utils => de/gultsch/common}/Patterns.java (86%) create mode 100644 src/test/java/de/gultsch/common/MiniUriTest.java create mode 100644 src/test/java/de/gultsch/common/PatternTest.java diff --git a/src/main/java/de/gultsch/common/MiniUri.java b/src/main/java/de/gultsch/common/MiniUri.java new file mode 100644 index 0000000000000000000000000000000000000000..7768e2da60f96e03768759bc544889e317df4ba5 --- /dev/null +++ b/src/main/java/de/gultsch/common/MiniUri.java @@ -0,0 +1,110 @@ +package de.gultsch.common; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Splitter; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.NonNull; + +public class MiniUri { + + private static final String EMPTY_STRING = ""; + + private final String scheme; + private final String authority; + private final String path; + private final Map parameter; + + public MiniUri(final String uri) { + final var schemeAndRest = Splitter.on(':').limit(2).splitToList(uri); + if (schemeAndRest.size() < 2) { + this.scheme = uri; + this.authority = null; + this.path = null; + this.parameter = Collections.emptyMap(); + return; + } + this.scheme = schemeAndRest.get(0); + final var rest = schemeAndRest.get(1); + final var authorityPathAndQuery = Splitter.on('?').limit(2).splitToList(rest); + final var authorityPath = authorityPathAndQuery.get(0); + System.out.println("authorityPath " + authorityPath); + if (authorityPath.length() >= 2 && authorityPath.startsWith("//")) { + final var authorityPathParts = + Splitter.on('/').limit(2).splitToList(authorityPath.substring(2)); + this.authority = authorityPathParts.get(0); + this.path = authorityPathParts.size() == 2 ? authorityPathParts.get(1) : null; + } else { + this.authority = null; + // TODO path ; style path components from something like geo uri + this.path = authorityPath; + } + if (authorityPathAndQuery.size() == 2) { + this.parameter = parseParameters(authorityPathAndQuery.get(1), getDelimiter(scheme)); + } else { + this.parameter = Collections.emptyMap(); + } + } + + private static char getDelimiter(final String scheme) { + return switch (scheme) { + case "xmpp", "geo" -> ';'; + default -> '&'; + }; + } + + private static Map parseParameters(final String query, final char separator) { + final ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + for (final String pair : Splitter.on(separator).split(query)) { + final String[] parts = pair.split("=", 2); + if (parts.length == 0) { + continue; + } + final String key = parts[0].toLowerCase(Locale.US); + if (parts.length == 2) { + try { + builder.put(key, URLDecoder.decode(parts[1], "UTF-8")); + } catch (final UnsupportedEncodingException e) { + builder.put(key, EMPTY_STRING); + } + } else { + builder.put(key, EMPTY_STRING); + } + } + return builder.build(); + } + + @NonNull + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("scheme", scheme) + .add("authority", authority) + .add("path", path) + .add("parameter", parameter) + .toString(); + } + + public String getScheme() { + return this.scheme; + } + + public String getAuthority() { + return this.authority; + } + + public String getPath() { + return Strings.isNullOrEmpty(this.path) || this.authority == null + ? this.path + : '/' + this.path; + } + + public Map getParameter() { + return this.parameter; + } +} diff --git a/src/main/java/eu/siacs/conversations/utils/Patterns.java b/src/main/java/de/gultsch/common/Patterns.java similarity index 86% rename from src/main/java/eu/siacs/conversations/utils/Patterns.java rename to src/main/java/de/gultsch/common/Patterns.java index 3b858da9ad954f5551c1644e5690758820b8d5a0..617a6b39ce0e023b3ce65acd901b2bc6f20f0b09 100644 --- a/src/main/java/eu/siacs/conversations/utils/Patterns.java +++ b/src/main/java/de/gultsch/common/Patterns.java @@ -1,4 +1,4 @@ -package eu.siacs.conversations.utils; +package de.gultsch.common; import java.util.regex.Pattern; @@ -6,7 +6,7 @@ public class Patterns { public static final Pattern URI_GENERIC = Pattern.compile( - "(?<=^|\\s|\\()(tel|xmpp|http|https|geo|mailto|web\\+ap):[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+(\\([\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+\\))*[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]*"); + "(?<=^|\\p{Zs}|\\p{P})(tel|xmpp|http|https|geo|mailto|web\\+ap|gemini):[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+(\\([\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+\\))*[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]*"); public static final Pattern URI_TEL = Pattern.compile("^tel:\\+?(\\d{1,4}[-./()\\s]?)*\\d{1,4}(;.*)?$"); diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index 7516e8caa38fab6cec2b9933d888539cec797b24..c8f3fa0f35b97a7a7c36ab8142a5b0d6c9f4686b 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -8,6 +8,7 @@ import com.google.common.base.Strings; import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableSet; import com.google.common.primitives.Longs; +import de.gultsch.common.Patterns; import eu.siacs.conversations.Config; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.FingerprintStatus; @@ -18,7 +19,6 @@ import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.Emoticons; import eu.siacs.conversations.utils.MessageUtils; import eu.siacs.conversations.utils.MimeUtils; -import eu.siacs.conversations.utils.Patterns; import eu.siacs.conversations.utils.UIHelper; import eu.siacs.conversations.xmpp.Jid; import java.lang.ref.WeakReference; diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index bd3a34555c25f54bd56a757368d380bf7cbdf7be..4bb0ab83b137b3328aab4b7dee7732b27aafc8b7 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -65,6 +65,7 @@ import androidx.databinding.DataBindingUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.common.base.Optional; import com.google.common.collect.ImmutableList; +import de.gultsch.common.Patterns; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.axolotl.AxolotlService; @@ -112,7 +113,6 @@ import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.GeoHelper; import eu.siacs.conversations.utils.MessageUtils; import eu.siacs.conversations.utils.NickValidityChecker; -import eu.siacs.conversations.utils.Patterns; import eu.siacs.conversations.utils.PermissionUtils; import eu.siacs.conversations.utils.QuickLoader; import eu.siacs.conversations.utils.StylingHelper; diff --git a/src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java b/src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java index 5e768692ac41ac5a32531acc10801955bdfc20b7..9964b4d9f30f01960a8f0a11673acd39bc849219 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java +++ b/src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java @@ -38,8 +38,8 @@ import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; +import de.gultsch.common.Patterns; import eu.siacs.conversations.ui.text.FixedURLSpan; -import eu.siacs.conversations.utils.Patterns; import eu.siacs.conversations.utils.XmppUri; import java.util.Arrays; import java.util.Collection; diff --git a/src/main/java/eu/siacs/conversations/utils/GeoHelper.java b/src/main/java/eu/siacs/conversations/utils/GeoHelper.java index 03cc78cee3f9252de5669a3722071a1467ec112d..0616b11f5979693ad95b37f7fe3283ed26027e9b 100644 --- a/src/main/java/eu/siacs/conversations/utils/GeoHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/GeoHelper.java @@ -5,6 +5,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.preference.PreferenceManager; +import de.gultsch.common.Patterns; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversational; diff --git a/src/main/java/eu/siacs/conversations/utils/IP.java b/src/main/java/eu/siacs/conversations/utils/IP.java index 33e989ee3d189d83e2c055b87b768c26b0cff91b..b259ec3dcbb874ae1d9ea4d6f750bb9597ce3a5f 100644 --- a/src/main/java/eu/siacs/conversations/utils/IP.java +++ b/src/main/java/eu/siacs/conversations/utils/IP.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.utils; import com.google.common.net.InetAddresses; +import de.gultsch.common.Patterns; import java.net.InetAddress; public class IP { diff --git a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java index 3bb877721a6eafa2ef454864589fec4b29031d71..953e6e1cc176725eb53f6dce0b75e318a03fdd1f 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java @@ -21,6 +21,7 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.primitives.Ints; +import de.gultsch.common.Patterns; import eu.siacs.conversations.AppSettings; import eu.siacs.conversations.BuildConfig; import eu.siacs.conversations.Config; @@ -49,7 +50,6 @@ import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.util.PendingItem; import eu.siacs.conversations.utils.AccountUtils; import eu.siacs.conversations.utils.CryptoHelper; -import eu.siacs.conversations.utils.Patterns; import eu.siacs.conversations.utils.PhoneHelper; import eu.siacs.conversations.utils.Resolver; import eu.siacs.conversations.utils.SSLSockets; @@ -97,7 +97,6 @@ import im.conversations.android.xmpp.model.sm.StreamManagement; import im.conversations.android.xmpp.model.stanza.Iq; import im.conversations.android.xmpp.model.stanza.Presence; import im.conversations.android.xmpp.model.stanza.Stanza; -import im.conversations.android.xmpp.model.streams.Features; import im.conversations.android.xmpp.model.streams.StreamError; import im.conversations.android.xmpp.model.tls.Proceed; import im.conversations.android.xmpp.model.tls.StartTls; diff --git a/src/test/java/de/gultsch/common/MiniUriTest.java b/src/test/java/de/gultsch/common/MiniUriTest.java new file mode 100644 index 0000000000000000000000000000000000000000..977fcc3710a16167c1af5811f42134a70b488e67 --- /dev/null +++ b/src/test/java/de/gultsch/common/MiniUriTest.java @@ -0,0 +1,62 @@ +package de.gultsch.common; + +import com.google.common.collect.ImmutableMap; +import org.junit.Assert; +import org.junit.Test; + +public class MiniUriTest { + + @Test + public void httpsUrl() { + final var miniUri = new MiniUri("https://example.com"); + Assert.assertEquals("https", miniUri.getScheme()); + Assert.assertEquals("example.com", miniUri.getAuthority()); + Assert.assertNull(miniUri.getPath()); + } + + @Test + public void httpsUrlHtml() { + final var miniUri = new MiniUri("https://example.com/test.html"); + Assert.assertEquals("https", miniUri.getScheme()); + Assert.assertEquals("example.com", miniUri.getAuthority()); + Assert.assertEquals("/test.html", miniUri.getPath()); + } + + @Test + public void httpsUrlCgiFooBar() { + final var miniUri = new MiniUri("https://example.com/test.cgi?foo=bar"); + Assert.assertEquals("https", miniUri.getScheme()); + Assert.assertEquals("example.com", miniUri.getAuthority()); + Assert.assertEquals("/test.cgi", miniUri.getPath()); + Assert.assertEquals(ImmutableMap.of("foo", "bar"), miniUri.getParameter()); + } + + @Test + public void xmppUri() { + final var miniUri = new MiniUri("xmpp:user@example.com"); + Assert.assertEquals("xmpp", miniUri.getScheme()); + Assert.assertNull(miniUri.getAuthority()); + Assert.assertEquals("user@example.com", miniUri.getPath()); + } + + @Test + public void xmppUriJoin() { + final var miniUri = new MiniUri("xmpp:room@chat.example.com?join"); + Assert.assertEquals("xmpp", miniUri.getScheme()); + Assert.assertNull(miniUri.getAuthority()); + Assert.assertEquals("room@chat.example.com", miniUri.getPath()); + Assert.assertEquals(ImmutableMap.of("join", ""), miniUri.getParameter()); + } + + @Test + public void xmppUriMessage() { + final var miniUri = + new MiniUri("xmpp:romeo@montague.net?message;body=Here%27s%20a%20test%20message"); + Assert.assertEquals("xmpp", miniUri.getScheme()); + Assert.assertNull(miniUri.getAuthority()); + Assert.assertEquals("romeo@montague.net", miniUri.getPath()); + Assert.assertEquals( + ImmutableMap.of("message", "", "body", "Here's a test message"), + miniUri.getParameter()); + } +} diff --git a/src/test/java/de/gultsch/common/PatternTest.java b/src/test/java/de/gultsch/common/PatternTest.java new file mode 100644 index 0000000000000000000000000000000000000000..fbff97269fa32bb4ed2e64baaed350b1abbd7391 --- /dev/null +++ b/src/test/java/de/gultsch/common/PatternTest.java @@ -0,0 +1,94 @@ +package de.gultsch.common; + +import com.google.common.collect.ImmutableList; +import java.util.Arrays; +import java.util.regex.MatchResult; +import java.util.stream.Collectors; +import org.junit.Assert; +import org.junit.Test; + +public class PatternTest { + + @Test + public void shortImMessage() { + final var message = + "Hi. I'm refactoring how URIs are linked in Conversations. We now support more URI" + + " schemes like mailto:user@example.com and tel:+1-269-555-0107 and obviously" + + " maintain support for things like" + + " xmpp:conversations@conference.siacs.eu?join and https://example.com however" + + " we no longer link domains that aren't actual URIs like example.com to avoid" + + " some false positives."; + + final var matches = + Patterns.URI_GENERIC + .matcher(message) + .results() + .map(MatchResult::group) + .collect(Collectors.toList()); + + Assert.assertEquals( + Arrays.asList( + "mailto:user@example.com", + "tel:+1-269-555-0107", + "xmpp:conversations@conference.siacs.eu?join", + "https://example.com"), + matches); + } + + @Test + public void ambiguous() { + final var message = + "Please find more information in the corresponding page on Wikipedia" + + " (https://en.wikipedia.org/wiki/Ambiguity_(disambiguation)). Let me know if" + + " you have questions!"; + final var matches = + Patterns.URI_GENERIC + .matcher(message) + .results() + .map(MatchResult::group) + .collect(Collectors.toList()); + + Assert.assertEquals( + ImmutableList.of("https://en.wikipedia.org/wiki/Ambiguity_(disambiguation)"), + matches); + } + + @Test + public void parenthesis() { + final var message = "Daniel is on Mastodon (https://gultsch.social/@daniel)"; + final var matches = + Patterns.URI_GENERIC + .matcher(message) + .results() + .map(MatchResult::group) + .collect(Collectors.toList()); + + Assert.assertEquals(ImmutableList.of("https://gultsch.social/@daniel"), matches); + } + + @Test + public void fullWidthSpace() { + final var message = "\u3000https://conversations.im"; + final var matches = + Patterns.URI_GENERIC + .matcher(message) + .results() + .map(MatchResult::group) + .collect(Collectors.toList()); + + Assert.assertEquals(ImmutableList.of("https://conversations.im"), matches); + } + + @Test + public void fullWidthColon() { + final var message = "\uFF1Ahttps://conversations.im"; + final var matches = + Patterns.URI_GENERIC + .matcher(message) + .results() + .map(MatchResult::group) + .collect(Collectors.toList()); + + Assert.assertEquals(ImmutableList.of("https://conversations.im"), matches); + } +}