make copy uri context menu work with new schemes

Daniel Gultsch created

Change summary

build.gradle                                                      |  4 
src/main/java/de/gultsch/common/MiniUri.java                      |  6 
src/main/java/de/gultsch/common/Patterns.java                     |  2 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java | 24 
src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java       | 74 
src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java       | 73 
src/main/res/values/strings.xml                                   |  8 
src/test/java/de/gultsch/common/PatternTest.java                  | 26 
8 files changed, 120 insertions(+), 97 deletions(-)

Detailed changes

build.gradle πŸ”—

@@ -99,6 +99,10 @@ dependencies {
     implementation 'com.google.guava:guava:33.4.0-android'
     quicksyImplementation 'io.michaelrocks:libphonenumber-android:8.13.52'
     implementation 'im.conversations.webrtc:webrtc-android:129.0.0'
+
+    //Testing
+    testImplementation 'junit:junit:4.13.2'
+
 }
 
 ext {

src/main/java/de/gultsch/common/MiniUri.java πŸ”—

@@ -15,12 +15,14 @@ public class MiniUri {
 
     private static final String EMPTY_STRING = "";
 
+    private final String raw;
     private final String scheme;
     private final String authority;
     private final String path;
     private final Map<String, String> parameter;
 
     public MiniUri(final String uri) {
+        this.raw = uri;
         final var schemeAndRest = Splitter.on(':').limit(2).splitToList(uri);
         if (schemeAndRest.size() < 2) {
             this.scheme = uri;
@@ -104,6 +106,10 @@ public class MiniUri {
                 : '/' + this.path;
     }
 
+    public String getRaw() {
+        return this.raw;
+    }
+
     public Map<String, String> getParameter() {
         return this.parameter;
     }

src/main/java/de/gultsch/common/Patterns.java πŸ”—

@@ -6,7 +6,7 @@ public class Patterns {
 
     public static final Pattern URI_GENERIC =
             Pattern.compile(
-                    "(?<=^|\\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}\\-._~:/?#\\[\\]@!$&'*+,;=%]*");
+                    "(?<=^|\\p{Z}|\\s|\\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/ui/ConversationFragment.java πŸ”—

@@ -31,7 +31,6 @@ import android.os.SystemClock;
 import android.preference.PreferenceManager;
 import android.provider.MediaStore;
 import android.text.Editable;
-import android.text.SpannableStringBuilder;
 import android.text.TextUtils;
 import android.util.Log;
 import android.view.ContextMenu;
@@ -65,6 +64,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 com.google.common.collect.Iterables;
 import de.gultsch.common.Patterns;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -100,6 +100,7 @@ import eu.siacs.conversations.ui.util.EditMessageActionModeCallback;
 import eu.siacs.conversations.ui.util.ListViewUtils;
 import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
 import eu.siacs.conversations.ui.util.MucDetailsContextMenuHelper;
+import eu.siacs.conversations.ui.util.MyLinkify;
 import eu.siacs.conversations.ui.util.PendingItem;
 import eu.siacs.conversations.ui.util.PresenceSelector;
 import eu.siacs.conversations.ui.util.ScrollState;
@@ -1347,13 +1348,22 @@ public class ConversationFragment extends XmppFragment
                     && t == null) {
                 copyMessage.setVisible(true);
                 quoteMessage.setVisible(!showError && !MessageUtils.prepareQuote(m).isEmpty());
-                final String scheme =
-                        ShareUtil.getLinkScheme(new SpannableStringBuilder(m.getBody()));
-                if ("xmpp".equals(scheme)) {
-                    copyLink.setTitle(R.string.copy_jabber_id);
-                    copyLink.setVisible(true);
-                } else if (scheme != null) {
+                final var firstUri = Iterables.getFirst(MyLinkify.getLinks(m.getBody()), null);
+                if (firstUri != null) {
+                    final var scheme = firstUri.getScheme();
+                    final @StringRes int resForScheme =
+                            switch (scheme) {
+                                case "xmpp" -> R.string.copy_jabber_id;
+                                case "http", "https", "gemini" -> R.string.copy_link;
+                                case "geo" -> R.string.copy_geo_uri;
+                                case "tel" -> R.string.copy_telephone_number;
+                                case "mailto" -> R.string.copy_email_address;
+                                default -> R.string.copy_URI;
+                            };
+                    copyLink.setTitle(resForScheme);
                     copyLink.setVisible(true);
+                } else {
+                    copyLink.setVisible(false);
                 }
             }
             if (m.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED && !deleted) {

src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java πŸ”—

@@ -31,72 +31,50 @@ package eu.siacs.conversations.ui.util;
 
 import android.net.Uri;
 import android.text.Editable;
-import android.text.style.URLSpan;
 import android.text.util.Linkify;
 import com.google.common.base.Splitter;
-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.MiniUri;
 import de.gultsch.common.Patterns;
 import eu.siacs.conversations.ui.text.FixedURLSpan;
 import eu.siacs.conversations.utils.XmppUri;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Comparator;
 import java.util.List;
-import java.util.Objects;
 
 public class MyLinkify {
 
     private static final Linkify.MatchFilter MATCH_FILTER =
-            (s, start, end) -> {
-                final var match = s.subSequence(start, end);
-                final var scheme =
-                        Iterables.getFirst(Splitter.on(':').limit(2).splitToList(match), null);
-                if (scheme == null) {
-                    return false;
-                }
-                return switch (scheme) {
-                    case "tel" -> Patterns.URI_TEL.matcher(match).matches();
-                    case "http", "https" -> Patterns.URI_HTTP.matcher(match).matches();
-                    case "geo" -> Patterns.URI_GEO.matcher(match).matches();
-                    case "xmpp" -> new XmppUri(Uri.parse(match.toString())).isValidJid();
-                    case "web+ap" -> Patterns.URI_WEB_AP.matcher(match).matches();
-                    default -> true;
-                };
-            };
+            (s, start, end) -> isPassAdditionalValidation(s.subSequence(start, end).toString());
+
+    private static boolean isPassAdditionalValidation(final String match) {
+        final var scheme = Iterables.getFirst(Splitter.on(':').limit(2).splitToList(match), null);
+        if (scheme == null) {
+            return false;
+        }
+        return switch (scheme) {
+            case "tel" -> Patterns.URI_TEL.matcher(match).matches();
+            case "http", "https" -> Patterns.URI_HTTP.matcher(match).matches();
+            case "geo" -> Patterns.URI_GEO.matcher(match).matches();
+            case "xmpp" -> new XmppUri(Uri.parse(match)).isValidJid();
+            case "web+ap" -> Patterns.URI_WEB_AP.matcher(match).matches();
+            default -> true;
+        };
+    }
 
     public static void addLinks(final Editable body) {
         Linkify.addLinks(body, Patterns.URI_GENERIC, null, MATCH_FILTER, null);
         FixedURLSpan.fix(body);
     }
 
-    public static List<String> extractLinks(final Editable body) {
-        MyLinkify.addLinks(body);
-        final Collection<URLSpan> spans =
-                Arrays.asList(body.getSpans(0, body.length() - 1, URLSpan.class));
-        final Collection<UrlWrapper> urlWrappers =
-                Collections2.filter(
-                        Collections2.transform(
-                                spans,
-                                s ->
-                                        s == null
-                                                ? null
-                                                : new UrlWrapper(body.getSpanStart(s), s.getURL())),
-                        Objects::nonNull);
-        List<UrlWrapper> sorted =
-                ImmutableList.sortedCopyOf(Comparator.comparingInt(a -> a.position), urlWrappers);
-        return Lists.transform(sorted, uw -> uw.url);
-    }
-
-    private static class UrlWrapper {
-        private final int position;
-        private final String url;
-
-        private UrlWrapper(int position, String url) {
-            this.position = position;
-            this.url = url;
+    public static List<MiniUri> getLinks(final String body) {
+        final var builder = new ImmutableList.Builder<MiniUri>();
+        final var matcher = Patterns.URI_GENERIC.matcher(body);
+        while (matcher.find()) {
+            final var match = matcher.group();
+            if (isPassAdditionalValidation(match)) {
+                builder.add(new MiniUri(match));
+            }
         }
+        return builder.build();
     }
 }

src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java πŸ”—

@@ -31,20 +31,23 @@ package eu.siacs.conversations.ui.util;
 
 import android.content.ActivityNotFoundException;
 import android.content.Intent;
-import android.net.Uri;
-import android.text.SpannableStringBuilder;
 import android.widget.Toast;
+import androidx.annotation.StringRes;
+import com.google.common.collect.Iterables;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.ui.ConversationsActivity;
 import eu.siacs.conversations.ui.XmppActivity;
-import eu.siacs.conversations.utils.XmppUri;
-import eu.siacs.conversations.xmpp.Jid;
+import java.util.Arrays;
+import java.util.Collection;
 
 public class ShareUtil {
 
+    private static final Collection<String> SCHEMES_COPY_PATH_ONLY =
+            Arrays.asList("xmpp", "mailto", "tel");
+
     public static void share(XmppActivity activity, Message message) {
         Intent shareIntent = new Intent();
         shareIntent.setAction(Intent.ACTION_SEND);
@@ -120,44 +123,32 @@ public class ShareUtil {
     }
 
     public static void copyLinkToClipboard(final XmppActivity activity, final Message message) {
-        final SpannableStringBuilder body = new SpannableStringBuilder(message.getBody());
-        for (final String url : MyLinkify.extractLinks(body)) {
-            final Uri uri = Uri.parse(url);
-            if ("xmpp".equals(uri.getScheme())) {
-                try {
-                    final Jid jid = new XmppUri(uri).getJid();
-                    if (activity.copyTextToClipboard(
-                            jid.asBareJid().toString(), R.string.account_settings_jabber_id)) {
-                        Toast.makeText(
-                                        activity,
-                                        R.string.jabber_id_copied_to_clipboard,
-                                        Toast.LENGTH_SHORT)
-                                .show();
-                    }
-                    return;
-                } catch (final Exception e) {
-                    return;
-                }
-            } else {
-                if (activity.copyTextToClipboard(url, R.string.web_address)) {
-                    Toast.makeText(activity, R.string.url_copied_to_clipboard, Toast.LENGTH_SHORT)
-                            .show();
-                }
-                return;
-            }
+        final var firstUri = Iterables.getFirst(MyLinkify.getLinks(message.getBody()), null);
+        if (firstUri == null) {
+            return;
         }
-    }
-
-    public static String getLinkScheme(final SpannableStringBuilder body) {
-        MyLinkify.addLinks(body);
-        for (final String url : MyLinkify.extractLinks(body)) {
-            final Uri uri = Uri.parse(url);
-            if ("xmpp".equals(uri.getScheme())) {
-                return uri.getScheme();
-            } else {
-                return "http";
-            }
+        final String clip;
+        if (SCHEMES_COPY_PATH_ONLY.contains(firstUri.getScheme())) {
+            clip = firstUri.getPath();
+        } else {
+            clip = firstUri.getRaw();
+        }
+        final @StringRes int label =
+                switch (firstUri.getScheme()) {
+                    case "http", "https", "gemini" -> R.string.web_address;
+                    case "xmpp" -> R.string.account_settings_jabber_id;
+                    default -> R.string.uri;
+                };
+        final @StringRes int toast =
+                switch (firstUri.getScheme()) {
+                    case "http", "https", "gemini", "web+ap" -> R.string.url_copied_to_clipboard;
+                    case "xmpp" -> R.string.jabber_id_copied_to_clipboard;
+                    case "tel" -> R.string.copied_phone_number;
+                    case "mailto" -> R.string.copied_email_address;
+                    default -> R.string.uri_copied_to_clipboard;
+                };
+        if (activity.copyTextToClipboard(clip, label)) {
+            Toast.makeText(activity, toast, Toast.LENGTH_SHORT).show();
         }
-        return null;
     }
 }

src/main/res/values/strings.xml πŸ”—

@@ -319,6 +319,8 @@
     <string name="retry_with_p2p">Retry with P2P</string>
     <string name="file_url">File URL</string>
     <string name="url_copied_to_clipboard">Copied URL to clipboard</string>
+    <string name="uri_copied_to_clipboard">Copied URI to clipboard</string>
+    <string name="uri">URI</string>
     <string name="jabber_id_copied_to_clipboard">Copied XMPP address to clipboard</string>
     <string name="error_message_copied_to_clipboard">Copied error message to clipboard</string>
     <string name="web_address">web address</string>
@@ -747,7 +749,13 @@
     <string name="pref_use_share_location_plugin">Share Location Plugin</string>
     <string name="pref_use_share_location_plugin_summary">Use the Share Location Plugin instead of the built-in map</string>
     <string name="copy_link">Copy web address</string>
+    <string name="copy_URI">Copy URI</string>
+    <string name="copy_telephone_number">Copy phone number</string>
+    <string name="copy_geo_uri">Copy geo location</string>
     <string name="copy_jabber_id">Copy XMPP address</string>
+    <string name="copy_email_address">Copy email address</string>
+    <string name="copied_email_address">Copied email address to clipboard</string>
+    <string name="copied_phone_number">Copied phone number to clipboard</string>
     <string name="p1_s3_filetransfer">HTTP File Sharing for S3</string>
     <string name="pref_start_search">Direct Search</string>
     <string name="pref_start_search_summary">At β€˜New chat’ screen open keyboard and place cursor in search field</string>

src/test/java/de/gultsch/common/PatternTest.java πŸ”—

@@ -91,4 +91,30 @@ public class PatternTest {
 
         Assert.assertEquals(ImmutableList.of("https://conversations.im"), matches);
     }
+
+    @Test
+    public void newLine() {
+        final var message = "\nxmpp:example.com";
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertEquals(ImmutableList.of("xmpp:example.com"), matches);
+    }
+
+    @Test
+    public void code() {
+        final var message = "`xmpp:example.com`";
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertTrue(matches.isEmpty());
+    }
 }