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

Issue:
Surround URL pasted from clipboard with angle brackets when composing a message
### Checklist

- [X] I have used the search function to see if someone else has already submitted the same feature request.
- [X] I will describe the problem with as much detail as possible.
- [X] This issue only contains a request for one single feature, **not** multiple (related) features.

### App version

6.509

### Problem you are trying to solve

Sometimes people compose text and include URLs in parentheses. This presents a challenge to apps trying to linkify URLs in this text because `(` and `)` are valid characters in a URL that don't have to be encoded.

Examples:
* `some text here (https://domain.example/path)`
* `(some text here: https://domain.example/path)`
* `(some text here and then a URL on a new line`
  `https://domain.example/path)`
* `(some text here :) https://domain.example/path)`
* `(some text here https://domain.example/path_(still_part_of_the_url))`

`https://domain.example/path)` is a valid URL, but probably not what the sender meant.

For a human reader it's mostly easy to see what is part of the URL and what is part of the surrounding text. However, I hope you can see that a piece of code trying to come to the same conclusion gets very complex pretty soon.

### Suggested solution

Make things easier for apps trying to find URLs in plain text, by surrounding URLs pasted from the clipboard in angle brackets (that can't be part of a URL in their unencoded form).

The examples from above turn into:
* `some text here (<https://domain.example/path>)`
* `(some text here: <https://domain.example/path>)`
* `(some text here and then a URL on a new line`
  `<https://domain.example/path>)`
* `(some text here :) <https://domain.example/path>)`
* `(some text here <https://domain.example/path_(still_part_of_the_url)>)`


### Screenshots / Drawings / Technical details

_No response_


Discussion hints:
@cketti In the case that the user pastes in some texts with a link like `This is a link https://link.com` do we convert the text to `This is a link <https://link.com>` or the angle brackets should only be added when a link is pasted with no other text like `https://link.com`.
Should I also handle linked without the `http` or `https` part eg `www.link.com`?
Only pasted text that consists solely of a URI supported by `UriMatcher` should be surrounded with angle brackets.

Pull request: Wrap pasted URI in angle brackets
Fixes #6756

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

Fix patch:
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 891bb58524f..b970b83c3bd 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
@@ -31,4 +31,20 @@ object UriMatcher {
             parser.parseUri(text, startIndex)
         }.filterNotNull().toList()
     }
+
+    @Suppress("ReturnCount")
+    fun isValidUri(text: CharSequence): Boolean {
+        val matchResult = URI_SCHEME.matchAt(text, 0) ?: return false
+
+        val matchGroup = matchResult.groups[1]!!
+        if (matchGroup.range.first != 0) {
+            return false
+        }
+
+        val scheme = matchGroup.value.lowercase()
+        val parser = SUPPORTED_URIS[scheme] ?: throw AssertionError("Scheme not found: $scheme")
+        val uriMatch = parser.parseUri(text, startPos = 0) ?: return false
+
+        return uriMatch.startIndex == 0 && uriMatch.endIndex == text.length
+    }
 }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java
index df7778c308f..6115d8f5844 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java
@@ -109,6 +109,7 @@
 import com.fsck.k9.ui.R;
 import com.fsck.k9.ui.base.K9Activity;
 import com.fsck.k9.ui.base.ThemeManager;
+import com.fsck.k9.ui.compose.WrapUriTextWatcher;
 import com.fsck.k9.ui.compose.QuotedMessageMvpView;
 import com.fsck.k9.ui.compose.QuotedMessagePresenter;
 import com.fsck.k9.ui.helper.SizeFormatter;
@@ -360,10 +361,12 @@ public void onTextChanged(CharSequence s, int start, int before, int count) {
         replyToView.addTextChangedListener(draftNeedsChangingTextWatcher);
         recipientMvpView.addTextChangedListener(draftNeedsChangingTextWatcher);
         quotedMessageMvpView.addTextChangedListener(draftNeedsChangingTextWatcher);
+        quotedMessageMvpView.addTextChangedListener(new WrapUriTextWatcher());
 
         subjectView.addTextChangedListener(draftNeedsChangingTextWatcher);
 
         messageContentView.addTextChangedListener(draftNeedsChangingTextWatcher);
+        messageContentView.addTextChangedListener(new WrapUriTextWatcher());
 
         /*
          * We set this to invisible by default. Other methods will turn it back on if it's
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/compose/WrapUriTextWatcher.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/compose/WrapUriTextWatcher.kt
new file mode 100644
index 00000000000..969dab1ad27
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/compose/WrapUriTextWatcher.kt
@@ -0,0 +1,41 @@
+package com.fsck.k9.ui.compose
+
+import android.text.Editable
+import android.text.TextWatcher
+import com.fsck.k9.message.html.UriMatcher
+
+private const val NO_INDEX = -1
+private const val MINIMUM_URI_LENGTH = 2 // scheme name + colon
+
+/**
+ * Wraps inserted URIs in angle brackets.
+ */
+class WrapUriTextWatcher : TextWatcher {
+    private var insertedStartIndex = NO_INDEX
+    private var insertedEndIndex = NO_INDEX
+
+    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+
+    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+        if (s == null || count < MINIMUM_URI_LENGTH) {
+            insertedStartIndex = NO_INDEX
+        } else {
+            insertedStartIndex = start
+            insertedEndIndex = start + count
+        }
+    }
+
+    override fun afterTextChanged(s: Editable?) {
+        // Changing s below will lead to this TextWatcher being invoked again. Keep necessary state local.
+        val insertedStartIndex = insertedStartIndex
+        val insertedEndIndex = insertedEndIndex
+
+        if (s != null && insertedStartIndex != NO_INDEX) {
+            val insertedText = s.subSequence(insertedStartIndex, insertedEndIndex)
+            if (UriMatcher.isValidUri(insertedText)) {
+                s.insert(insertedEndIndex, ">")
+                s.insert(insertedStartIndex, "<")
+            }
+        }
+    }
+}


Test patch:
diff --git a/app/core/src/test/java/com/fsck/k9/message/html/UriMatcherTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/UriMatcherTest.kt
index fd0b02ecd34..16ef2dcdcfa 100644
--- a/app/core/src/test/java/com/fsck/k9/message/html/UriMatcherTest.kt
+++ b/app/core/src/test/java/com/fsck/k9/message/html/UriMatcherTest.kt
@@ -1,10 +1,13 @@
 package com.fsck.k9.message.html
 
+import assertk.Assert
 import assertk.assertThat
 import assertk.assertions.hasSize
 import assertk.assertions.isEmpty
 import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
 import assertk.assertions.isNotEqualTo
+import assertk.assertions.isTrue
 import org.junit.Test
 
 class UriMatcherTest {
@@ -82,6 +85,27 @@ class UriMatcherTest {
         )
     }
 
+    @Test
+    fun `valid URIs`() {
+        assertThat("http://domain.example").isValidUri()
+        assertThat("https://domain.example/").isValidUri()
+        assertThat("mailto:user@domain.example").isValidUri()
+    }
+
+    @Test
+    fun `not URIs`() {
+        assertThat("some text here").isNotValidUri()
+        assertThat("some text including the string http:").isNotValidUri()
+        assertThat("some text including an email address without URI scheme: user@domain.example").isNotValidUri()
+    }
+
+    @Test
+    fun `valid URIs surrounded by other characters`() {
+        assertThat("text https://domain.example/").isNotValidUri()
+        assertThat("https://domain.example/ text").isNotValidUri()
+        assertThat("<https://domain.example/>").isNotValidUri()
+    }
+
     private fun assertNoMatch(text: String) {
         val uriMatches = UriMatcher.findUris(text)
         assertThat(uriMatches).isEmpty()
@@ -104,3 +128,11 @@ class UriMatcherTest {
         }
     }
 }
+
+private fun Assert<String>.isValidUri() = given { actual ->
+    assertThat(UriMatcher.isValidUri(actual)).isTrue()
+}
+
+private fun Assert<String>.isNotValidUri() = given { actual ->
+    assertThat(UriMatcher.isValidUri(actual)).isFalse()
+}