Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
Add support for linkifying more URI schemes
We currently linkify HTTP URLs and Bitcoin URIs when converting plain text to HTML. It'd be nice to have support for additional URI schemes. See #684.

* `bridge:` - Tor bridge
* `geo:` - Physical location
* `market:` - Link to app in Google Play (previously Android Market)
* `openpgp4fpr:` - OpenPGP key IDs and fingerprints
* `xmpp:` - Identify or interact with entities using XMPP

However, I'm concerned about the performance impact of this. Ideally we'd use something similar to [linkifyjs](https://github.com/SoapBox/linkifyjs) that goes through the text only once to find all URIs.

We might also want to think about linkifying supported URIs in HTML parts. That's bad when e.g. malicious URLs have been left unlinkified intentionally. But I believe in the majority of cases linkifying is what is expected by users.


Discussion hints:
I will like to give this a try. Can I work on this?
@karishnu: This issue is probably not suitable for a beginner.
We could add a generic URI ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986)) parser and allow the user to specify which URI schemes they want to have linkified.

Pull request: Add `GenericUriParser`
Remove support for linkifying `bitcoin:` and `ethereum:` URIs. The time where crypto currencies were a harmless playground for nerds are long gone.

Add a `UriParser` that matches generic URIs. We can use this to support linkifying arbitrary URI schemes. See #6545.

Closes #2265

Test command: ./gradlew :app:core:testDebugUnitTest --tests "com.fsck.k9.message.html.BitcoinUriParserTest" --tests "com.fsck.k9.message.html.EthereumUriParserTest" --tests "com.fsck.k9.message.html.GenericUriParserTest"

Fix patch:
diff --git a/app/core/src/main/java/com/fsck/k9/message/html/BitcoinUriParser.java b/app/core/src/main/java/com/fsck/k9/message/html/BitcoinUriParser.java
deleted file mode 100644
index 632231a00a4..00000000000
--- a/app/core/src/main/java/com/fsck/k9/message/html/BitcoinUriParser.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.fsck.k9.message.html;
-
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-
-class BitcoinUriParser implements UriParser {
-    private static final Pattern BITCOIN_URI_PATTERN =
-            Pattern.compile("bitcoin:[1-9a-km-zA-HJ-NP-Z]{27,34}(\\?[a-zA-Z0-9$\\-_.+!*'(),%:@&=]*)?");
-
-    @Nullable
-    @Override
-    public UriMatch parseUri(@NotNull CharSequence text, int startPos) {
-        Matcher matcher = BITCOIN_URI_PATTERN.matcher(text);
-
-        if (!matcher.find(startPos) || matcher.start() != startPos) {
-            return null;
-        }
-
-        int startIndex = matcher.start();
-        int endIndex = matcher.end();
-        CharSequence uri = text.subSequence(startIndex, endIndex);
-        return new UriMatch(startIndex, endIndex, uri);
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/message/html/EthereumUriParser.java b/app/core/src/main/java/com/fsck/k9/message/html/EthereumUriParser.java
deleted file mode 100644
index 70f5878a23c..00000000000
--- a/app/core/src/main/java/com/fsck/k9/message/html/EthereumUriParser.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.fsck.k9.message.html;
-
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-
-/**
- * Parses ERC-67 URIs
- * https://github.com/ethereum/EIPs/issues/67
- */
-class EthereumUriParser implements UriParser {
-    private static final Pattern ETHEREUM_URI_PATTERN =
-            Pattern.compile("ethereum:0x[0-9a-fA-F]*(\\?[a-zA-Z0-9$\\-_.+!*'(),%:@&=]*)?");
-
-    @Nullable
-    @Override
-    public UriMatch parseUri(@NotNull CharSequence text, int startPos) {
-        Matcher matcher = ETHEREUM_URI_PATTERN.matcher(text);
-
-        if (!matcher.find(startPos) || matcher.start() != startPos) {
-            return null;
-        }
-
-        int startIndex = matcher.start();
-        int endIndex = matcher.end();
-        CharSequence uri = text.subSequence(startIndex, endIndex);
-        return new UriMatch(startIndex, endIndex, uri);
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt b/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt
new file mode 100644
index 00000000000..14cede4cdc2
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt
@@ -0,0 +1,34 @@
+package com.fsck.k9.message.html
+
+import java.util.regex.Pattern
+
+/**
+ * Matches the URI generic syntax.
+ *
+ * See [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986).
+ */
+class GenericUriParser : UriParser {
+    override fun parseUri(text: CharSequence, startPos: Int): UriMatch? {
+        val matcher = PATTERN.matcher(text)
+        if (!matcher.find(startPos) || matcher.start() != startPos) return null
+
+        val startIndex = matcher.start()
+        val endIndex = matcher.end()
+        val uri = text.subSequence(startIndex, endIndex)
+
+        return UriMatch(startIndex, endIndex, uri)
+    }
+
+    companion object {
+        private const val SCHEME = "[a-zA-Z][a-zA-Z0-9+.\\-]*"
+        private const val AUTHORITY = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:\\[\\]@]*"
+        private const val PATH = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/]*"
+        private const val QUERY = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/?]*"
+        private const val FRAGMENT = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/?]*"
+
+        // This regular expression matches more than allowed by the generic URI syntax. So we might end up linkifying
+        // text that is not a proper URI. We leave apps actually handling the URI when the user clicks on such a link
+        // to deal with this case.
+        private val PATTERN = Pattern.compile("$SCHEME:(?://$AUTHORITY)?(?:$PATH)?(?:\\?$QUERY)?(?:#$FRAGMENT)?")
+    }
+}
diff --git a/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt b/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt
index 5dea02560e4..44329acfdb6 100644
--- a/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt
+++ b/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt
@@ -3,12 +3,14 @@ package com.fsck.k9.message.html
 object UriMatcher {
     private val SUPPORTED_URIS = run {
         val httpUriParser = HttpUriParser()
+        val genericUriParser = GenericUriParser()
         mapOf(
-            "ethereum:" to EthereumUriParser(),
-            "bitcoin:" to BitcoinUriParser(),
             "http:" to httpUriParser,
             "https:" to httpUriParser,
-            "rtsp:" to httpUriParser
+            "mailto:" to genericUriParser,
+            "matrix:" to genericUriParser,
+            "rtsp:" to httpUriParser,
+            "xmpp:" to genericUriParser,
         )
     }
 


Test patch:
diff --git a/app/core/src/test/java/com/fsck/k9/message/html/BitcoinUriParserTest.java b/app/core/src/test/java/com/fsck/k9/message/html/BitcoinUriParserTest.java
deleted file mode 100644
index 0478024cefe..00000000000
--- a/app/core/src/test/java/com/fsck/k9/message/html/BitcoinUriParserTest.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.fsck.k9.message.html;
-
-
-import org.junit.Test;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-
-
-public class BitcoinUriParserTest {
-    BitcoinUriParser parser = new BitcoinUriParser();
-
-
-    @Test
-    public void basicBitcoinUri() throws Exception {
-        assertValidUri("bitcoin:19W6QZkx8SYPG7BBCS7odmWGRxqRph5jFU");
-    }
-
-    @Test
-    public void bitcoinUriWithAmount() throws Exception {
-        assertValidUri("bitcoin:12A1MyfXbW6RhdRAZEqofac5jCQQjwEPBu?amount=1.2");
-    }
-
-    @Test
-    public void bitcoinUriWithQueryParameters() throws Exception {
-        assertValidUri("bitcoin:12A1MyfXbW6RhdRAZEqofac5jCQQjwEPBu?amount=1.2" +
-                "&message=Payment&label=Satoshi&extra=other-param");
-    }
-
-    @Test
-    public void uriInMiddleOfInput() throws Exception {
-        String prefix = "prefix ";
-        String uri = "bitcoin:12A1MyfXbW6RhdRAZEqofac5jCQQjwEPBu?amount=1.2";
-        String text = prefix + uri;
-
-        UriMatch uriMatch = parser.parseUri(text, prefix.length());
-
-        assertUriMatch(uri, uriMatch, prefix.length());
-    }
-
-    @Test
-    public void invalidScheme() throws Exception {
-        assertInvalidUri("bitcion:19W6QZkx8SYPG7BBCS7odmWGRxqRph5jFU");
-    }
-
-    @Test
-    public void invalidAddress() throws Exception {
-        assertInvalidUri("bitcoin:[invalid]");
-    }
-
-
-    private void assertValidUri(String uri) {
-        UriMatch uriMatch = parser.parseUri(uri, 0);
-        assertUriMatch(uri, uriMatch);
-    }
-
-    private void assertUriMatch(String uri, UriMatch uriMatch) {
-        assertUriMatch(uri, uriMatch, 0);
-    }
-
-    private void assertUriMatch(String uri, UriMatch uriMatch, int offset) {
-        assertNotNull(uriMatch);
-        assertEquals(offset, uriMatch.getStartIndex());
-        assertEquals(uri.length() + offset, uriMatch.getEndIndex());
-        assertEquals(uri, uriMatch.getUri().toString());
-    }
-
-    private void assertInvalidUri(String text) {
-        UriMatch uriMatch = parser.parseUri(text, 0);
-        assertNull(uriMatch);
-    }
-}
diff --git a/app/core/src/test/java/com/fsck/k9/message/html/EthereumUriParserTest.java b/app/core/src/test/java/com/fsck/k9/message/html/EthereumUriParserTest.java
deleted file mode 100644
index 9927f329c41..00000000000
--- a/app/core/src/test/java/com/fsck/k9/message/html/EthereumUriParserTest.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.fsck.k9.message.html;
-
-
-import org.junit.Test;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-
-
-public class EthereumUriParserTest {
-    EthereumUriParser parser = new EthereumUriParser();
-
-
-    @Test
-    public void basicEthereumUri() throws Exception {
-        assertValidUri("ethereum:0xfdf1210fc262c73d0436236a0e07be419babbbc4");
-    }
-
-    @Test
-    public void ethereumUriWithValue() throws Exception {
-        assertValidUri("ethereum:0xfdf1210fc262c73d0436236a0e07be419babbbc4?value=42");
-    }
-
-    @Test
-    public void ethereumUriWithQueryParameters() throws Exception {
-        assertValidUri("ethereum:0xfdf1210fc262c73d0436236a0e07be419babbbc4?value=42" +
-                "&gas=100000&bytecode=0xa9059cbb0000000000000000000000000000000dead");
-    }
-
-    @Test
-    public void uriInMiddleOfInput() throws Exception {
-        String prefix = "prefix ";
-        String uri = "ethereum:0xfdf1210fc262c73d0436236a0e07be419babbbc4?value=42";
-        String text = prefix + uri;
-
-        UriMatch uriMatch = parser.parseUri(text, prefix.length());
-
-        assertUriMatch(uri, uriMatch, prefix.length());
-    }
-
-    @Test
-    public void invalidScheme() throws Exception {
-        assertInvalidUri("ethereMU:0xfdf1210fc262c73d0436236a0e07be419babbbc4");
-    }
-
-    @Test
-    public void invalidAddress() throws Exception {
-        assertInvalidUri("ethereum:[invalid]");
-    }
-
-
-    private void assertValidUri(String uri) {
-        UriMatch uriMatch = parser.parseUri(uri, 0);
-        assertUriMatch(uri, uriMatch);
-    }
-
-    private void assertUriMatch(String uri, UriMatch uriMatch) {
-        assertUriMatch(uri, uriMatch, 0);
-    }
-
-    private void assertUriMatch(String uri, UriMatch uriMatch, int offset) {
-        assertNotNull(uriMatch);
-        assertEquals(offset, uriMatch.getStartIndex());
-        assertEquals(uri.length() + offset, uriMatch.getEndIndex());
-        assertEquals(uri, uriMatch.getUri().toString());
-    }
-
-    private void assertInvalidUri(String text) {
-        UriMatch uriMatch = parser.parseUri(text, 0);
-        assertNull(uriMatch);
-    }
-}
diff --git a/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt
new file mode 100644
index 00000000000..4dc0b1af15d
--- /dev/null
+++ b/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt
@@ -0,0 +1,71 @@
+package com.fsck.k9.message.html
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertNotNull
+import org.junit.Test
+
+class GenericUriParserTest {
+    private val parser = GenericUriParser()
+
+    @Test
+    fun `mailto URIs`() {
+        // Examples from RFC 6068
+        assertUriValid("mailto:chris@example.com")
+        assertUriValid("mailto:infobot@example.com?subject=current-issue")
+        assertUriValid("mailto:infobot@example.com?body=send%20current-issue")
+        assertUriValid("mailto:infobot@example.com?body=send%20current-issue%0D%0Asend%20index")
+        assertUriValid("mailto:list@example.org?In-Reply-To=%3C3469A91.D10AF4C@example.com%3E")
+        assertUriValid("mailto:majordomo@example.com?body=subscribe%20bamboo-l")
+        assertUriValid("mailto:joe@example.com?cc=bob@example.com&body=hello")
+        assertUriValid("mailto:gorby%25kremvax@example.com")
+        assertUriValid("mailto:unlikely%3Faddress@example.com?blat=foop")
+        assertUriValid("mailto:%22not%40me%22@example.org")
+        assertUriValid("mailto:%22oh%5C%5Cno%22@example.org")
+        assertUriValid("mailto:%22%5C%5C%5C%22it's%5C%20ugly%5C%5C%5C%22%22@example.org")
+        assertUriValid("mailto:user@example.org?subject=caf%C3%A9")
+        assertUriValid("mailto:user@example.org?subject=%3D%3Futf-8%3FQ%3Fcaf%3DC3%3DA9%3F%3D")
+        assertUriValid("mailto:user@example.org?subject=%3D%3Fiso-8859-1%3FQ%3Fcaf%3DE9%3F%3D")
+        assertUriValid("mailto:user@example.org?subject=caf%C3%A9&body=caf%C3%A9")
+        assertUriValid("mailto:user@%E7%B4%8D%E8%B1%86.example.org?subject=Test&body=NATTO")
+    }
+
+    @Test
+    fun `XMPP URIs`() {
+        // Examples from RFC 5122
+        assertUriValid("xmpp:node@example.com")
+        assertUriValid("xmpp://guest@example.com")
+        assertUriValid("xmpp://guest@example.com/support@example.com?message")
+        assertUriValid("xmpp:support@example.com?message")
+        assertUriValid("xmpp:example-node@example.com/some-resource")
+        assertUriValid("xmpp:example.com")
+        assertUriValid("xmpp:example-node@example.com?message")
+        assertUriValid("xmpp:example-node@example.com?message;subject=Hello%20World")
+        assertUriValid("xmpp:nasty!%23\$%25()*+,-.;=%3F%5B%5C%5D%5E_%60%7B%7C%7D~node@example.com")
+        assertUriValid("xmpp:node@example.com/repulsive%20!%23%22\$%25&'()*+,-.%2F:;%3C=%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~resource")
+        assertUriValid("xmpp:ji%C5%99i@%C4%8Dechy.example/v%20Praze")
+    }
+
+    @Test
+    fun `matrix URIs`() {
+        // Examples from MSC 2312
+        assertUriValid("matrix:r/someroom:example.org")
+        assertUriValid("matrix:u/me:example.org")
+        assertUriValid("matrix:r/someroom:example.org/e/Arbitrary_Event_Id")
+        assertUriValid("matrix:u/her:example.org")
+        assertUriValid("matrix:u/her:example.org?action=chat")
+        assertUriValid("matrix:roomid/rid:example.org")
+        assertUriValid("matrix:r/us:example.org")
+        assertUriValid("matrix:roomid/rid:example.org?action=join&via=example2.org")
+        assertUriValid("matrix:r/us:example.org?action=join")
+        assertUriValid("matrix:r/us:example.org/e/lol823y4bcp3qo4")
+        assertUriValid("matrix:roomid/rid:example.org/event/lol823y4bcp3qo4?via=example2.org")
+    }
+
+    private fun assertUriValid(input: String) {
+        val result = parser.parseUri(input, 0)
+
+        assertNotNull(result) { uriMatch ->
+            assertThat(uriMatch.uri).isEqualTo(input)
+        }
+    }
+}