support gemini URI scheme; start URIs on punct

Daniel Gultsch created

Change summary

src/main/java/de/gultsch/common/MiniUri.java                      | 110 +
src/main/java/de/gultsch/common/Patterns.java                     |   4 
src/main/java/eu/siacs/conversations/entities/Message.java        |   2 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java |   2 
src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java       |   2 
src/main/java/eu/siacs/conversations/utils/GeoHelper.java         |   1 
src/main/java/eu/siacs/conversations/utils/IP.java                |   1 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java     |   3 
src/test/java/de/gultsch/common/MiniUriTest.java                  |  62 
src/test/java/de/gultsch/common/PatternTest.java                  |  94 
10 files changed, 274 insertions(+), 7 deletions(-)

Detailed changes

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<String, String> 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<String, String> parseParameters(final String query, final char separator) {
+        final ImmutableMap.Builder<String, String> 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<String, String> getParameter() {
+        return this.parameter;
+    }
+}

src/main/java/eu/siacs/conversations/utils/Patterns.java → 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}(;.*)?$");

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;

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;

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;

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;

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;

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());
+    }
+}

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);
+    }
+}