diff --git a/build.gradle b/build.gradle index aeda2676648ac706168d1611b7b85895d6f5150a..b9986e588fd557816da36d13e470427873ba41f3 100644 --- a/build.gradle +++ b/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 { diff --git a/src/main/java/de/gultsch/common/MiniUri.java b/src/main/java/de/gultsch/common/MiniUri.java index 7768e2da60f96e03768759bc544889e317df4ba5..6ea24a90dd6d1dd497f03813e64cbc7dda7bf7e1 100644 --- a/src/main/java/de/gultsch/common/MiniUri.java +++ b/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 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 getParameter() { return this.parameter; } diff --git a/src/main/java/de/gultsch/common/Patterns.java b/src/main/java/de/gultsch/common/Patterns.java index 617a6b39ce0e023b3ce65acd901b2bc6f20f0b09..36245b4d8aa80bb4f50af79a8d2676eac3532859 100644 --- a/src/main/java/de/gultsch/common/Patterns.java +++ b/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}(;.*)?$"); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 4bb0ab83b137b3328aab4b7dee7732b27aafc8b7..cc6dfcd19178e3f7745010fa3cb17f94f3a6362d 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/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) { 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 9964b4d9f30f01960a8f0a11673acd39bc849219..d9d2055e4c4a44770588983c2a21e36c7a16ba4c 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java +++ b/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 extractLinks(final Editable body) { - MyLinkify.addLinks(body); - final Collection spans = - Arrays.asList(body.getSpans(0, body.length() - 1, URLSpan.class)); - final Collection urlWrappers = - Collections2.filter( - Collections2.transform( - spans, - s -> - s == null - ? null - : new UrlWrapper(body.getSpanStart(s), s.getURL())), - Objects::nonNull); - List 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 getLinks(final String body) { + final var builder = new ImmutableList.Builder(); + 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(); } } diff --git a/src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java b/src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java index bee0734676d1d252365f7ad3815bdd3110a01b5f..1602b225597ab29851acc876c93d6fe0ff18bd6f 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java +++ b/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 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; } } diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index cdc5d5b89a0846acd70d3a045a35838b38c8e0ac..af782605d13a0e14dcb03da9f019dee80291f4e3 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -319,6 +319,8 @@ Retry with P2P File URL Copied URL to clipboard + Copied URI to clipboard + URI Copied XMPP address to clipboard Copied error message to clipboard web address @@ -747,7 +749,13 @@ Share Location Plugin Use the Share Location Plugin instead of the built-in map Copy web address + Copy URI + Copy phone number + Copy geo location Copy XMPP address + Copy email address + Copied email address to clipboard + Copied phone number to clipboard HTTP File Sharing for S3 Direct Search At ‘New chat’ screen open keyboard and place cursor in search field diff --git a/src/test/java/de/gultsch/common/PatternTest.java b/src/test/java/de/gultsch/common/PatternTest.java index fbff97269fa32bb4ed2e64baaed350b1abbd7391..db18fc6c98322e530a0bd71a003389d5d2f36077 100644 --- a/src/test/java/de/gultsch/common/PatternTest.java +++ b/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()); + } }