From 347967700b5d34d2c82a2b033a350d79f2cc7f32 Mon Sep 17 00:00:00 2001
From: Benoit Marty <benoit@matrix.org>
Date: Tue, 9 Apr 2019 17:33:47 +0200
Subject: [PATCH] Linkification: import workaround done on Riot

---
 .../android/api/permalinks/MatrixLinkify.kt   |   2 -
 .../core/linkify/VectorAutoLinkPatterns.kt    |  42 ++++
 .../core/linkify/VectorLinkify.kt             | 187 ++++++++++++++++++
 .../timeline/factory/MessageItemFactory.kt    |  12 +-
 4 files changed, 235 insertions(+), 8 deletions(-)
 create mode 100644 vector/src/main/java/im/vector/riotredesign/core/linkify/VectorAutoLinkPatterns.kt
 create mode 100644 vector/src/main/java/im/vector/riotredesign/core/linkify/VectorLinkify.kt

diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/MatrixLinkify.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/MatrixLinkify.kt
index bd3ba99029..d09db8c476 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/MatrixLinkify.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/MatrixLinkify.kt
@@ -89,6 +89,4 @@ object MatrixLinkify {
             }
         }
     }
-
-
 }
\ No newline at end of file
diff --git a/vector/src/main/java/im/vector/riotredesign/core/linkify/VectorAutoLinkPatterns.kt b/vector/src/main/java/im/vector/riotredesign/core/linkify/VectorAutoLinkPatterns.kt
new file mode 100644
index 0000000000..af200a3f31
--- /dev/null
+++ b/vector/src/main/java/im/vector/riotredesign/core/linkify/VectorAutoLinkPatterns.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2019 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package im.vector.riotredesign.core.linkify
+
+import java.util.regex.Pattern
+
+
+/**
+ * Better support for geo URi
+ */
+object VectorAutoLinkPatterns {
+
+    //geo:
+    private const val LAT_OR_LONG_OR_ALT_NUMBER = "-?\\d+(?:\\.\\d+)?"
+    private const val COORDINATE_SYSTEM = ";crs=[\\w-]+"
+
+    val GEO_URI: Pattern = Pattern.compile("(?:geo:)?" +
+            "(" + LAT_OR_LONG_OR_ALT_NUMBER + ")" +
+            "," +
+            "(" + LAT_OR_LONG_OR_ALT_NUMBER + ")" +
+            "(?:" + "," + LAT_OR_LONG_OR_ALT_NUMBER + ")?" + //altitude
+            "(?:" + COORDINATE_SYSTEM + ")?" +
+            "(?:" + ";u=\\d+(?:\\.\\d+)?" + ")?" +//uncertainty in meters
+            "(?:" +
+            ";[\\w-]+=(?:[\\w-_.!~*'()]|%[\\da-f][\\da-f])+" + //dafuk
+            ")*"
+            , Pattern.CASE_INSENSITIVE)
+
+}
\ No newline at end of file
diff --git a/vector/src/main/java/im/vector/riotredesign/core/linkify/VectorLinkify.kt b/vector/src/main/java/im/vector/riotredesign/core/linkify/VectorLinkify.kt
new file mode 100644
index 0000000000..243c069cd3
--- /dev/null
+++ b/vector/src/main/java/im/vector/riotredesign/core/linkify/VectorLinkify.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2019 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package im.vector.riotredesign.core.linkify
+
+import android.text.Spannable
+import android.text.style.URLSpan
+import android.text.util.Linkify
+import androidx.core.text.util.LinkifyCompat
+import java.util.*
+
+object VectorLinkify {
+    /**
+     * Better support for auto link than the default implementation
+     */
+    fun addLinks(spannable: Spannable, keepExistingUrlSpan: Boolean = false) {
+        //we might want to modify some matches
+        val createdSpans = ArrayList<LinkSpec>()
+
+        if (keepExistingUrlSpan) {
+            //Keep track of existing URLSpans, and mark them as important
+            spannable.forEachSpanIndexed { _, urlSpan, start, end ->
+                createdSpans.add(LinkSpec(URLSpan(urlSpan.url), start, end, important = true))
+            }
+        }
+
+        //Use the framework first, the found span can then be manipulated if needed
+        LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS)
+
+        //we might want to modify some matches
+        spannable.forEachSpanIndexed { _, urlSpan, start, end ->
+            spannable.removeSpan(urlSpan)
+
+            //remove short PN, too much false positive
+            if (urlSpan.url?.startsWith("tel:") == true) {
+                if (end - start > 6) { //Do not match under 7 digit
+                    createdSpans.add(LinkSpec(URLSpan(urlSpan.url), start, end))
+                }
+                return@forEachSpanIndexed
+            }
+
+            //include mailto: if found before match
+            if (urlSpan.url?.startsWith("mailto:") == true) {
+                val protocolLength = "mailto:".length
+                if (start - protocolLength >= 0 && "mailto:" == spannable.substring(start - protocolLength, start)) {
+                    //modify to include the protocol
+                    createdSpans.add(LinkSpec(URLSpan(urlSpan.url), start - protocolLength, end))
+                } else {
+                    createdSpans.add(LinkSpec(URLSpan(urlSpan.url), start, end))
+                }
+
+                return@forEachSpanIndexed
+            }
+
+            //Handle url matches
+
+            //check trailing space
+            if (end < spannable.length - 1 && spannable[end] == '/') {
+                //modify the span to include the slash
+                val spec = LinkSpec(URLSpan(urlSpan.url + "/"), start, end + 1)
+                createdSpans.add(spec)
+                return@forEachSpanIndexed
+            }
+            //Try to do something for ending ) issues/3020
+            if (spannable[end - 1] == ')') {
+                var lbehind = end - 2
+                var isFullyContained = 1
+                while (lbehind > start) {
+                    val char = spannable[lbehind]
+                    if (char == '(') isFullyContained -= 1
+                    if (char == ')') isFullyContained += 1
+                    lbehind--
+                }
+                if (isFullyContained != 0) {
+                    //In this case we will return false to match, and manually add span if we want?
+                    val span = URLSpan(spannable.substring(start, end - 1))
+                    val spec = LinkSpec(span, start, end - 1)
+                    createdSpans.add(spec)
+                    return@forEachSpanIndexed
+                }
+            }
+
+            createdSpans.add(LinkSpec(URLSpan(urlSpan.url), start, end))
+        }
+
+        LinkifyCompat.addLinks(spannable, VectorAutoLinkPatterns.GEO_URI, "geo:", arrayOf("geo:"), geoMatchFilter, null)
+        spannable.forEachSpanIndexed { _, urlSpan, start, end ->
+            spannable.removeSpan(urlSpan)
+            createdSpans.add(LinkSpec(URLSpan(urlSpan.url), start, end))
+        }
+
+        pruneOverlaps(createdSpans)
+        for (spec in createdSpans) {
+            spannable.setSpan(spec.span, spec.start, spec.end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+        }
+    }
+
+    private fun pruneOverlaps(links: ArrayList<LinkSpec>) {
+        Collections.sort(links, COMPARATOR)
+        var len = links.size
+        var i = 0
+        while (i < len - 1) {
+            val a = links[i]
+            val b = links[i + 1]
+            var remove = -1
+
+            //test if there is an overlap
+            if (b.start in a.start until a.end) {
+                if (a.important != b.important) {
+                    remove = if (a.important) i + 1 else i
+                } else {
+                    when {
+                        b.end <= a.end ->
+                            //b is inside a -> b should be removed
+                            remove = i + 1
+                        a.end - a.start > b.end - b.start ->
+                            //overlap and a is bigger -> b should be removed
+                            remove = i + 1
+                        a.end - a.start < b.end - b.start ->
+                            //overlap and a is smaller -> a should be removed
+                            remove = i
+                    }
+                }
+
+                if (remove != -1) {
+                    links.removeAt(remove)
+                    len--
+                    continue
+                }
+            }
+            i++
+        }
+    }
+
+
+    private data class LinkSpec(val span: URLSpan,
+                                val start: Int,
+                                val end: Int,
+                                val important: Boolean = false)
+
+    private val COMPARATOR = Comparator<LinkSpec> { (_, startA, endA), (_, startB, endB) ->
+        if (startA < startB) {
+            return@Comparator -1
+        }
+
+        if (startA > startB) {
+            return@Comparator 1
+        }
+
+        if (endA < endB) {
+            return@Comparator 1
+        }
+
+        if (endA > endB) {
+            -1
+        } else 0
+    }
+
+    //Exclude short match that don't have geo: prefix, e.g do not highlight things like 1,2
+    private val geoMatchFilter = Linkify.MatchFilter { s, start, end ->
+        if (s[start] != 'g') { //doesn't start with geo:
+            return@MatchFilter end - start > 12
+        }
+        return@MatchFilter true
+    }
+
+    private inline fun Spannable.forEachSpanIndexed(action: (index: Int, urlSpan: URLSpan, start: Int, end: Int) -> Unit) {
+        getSpans(0, length, URLSpan::class.java)
+                .forEachIndexed { index, urlSpan ->
+                    val start = getSpanStart(urlSpan)
+                    val end = getSpanEnd(urlSpan)
+                    action.invoke(index, urlSpan, start, end)
+                }
+    }
+}
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt
index 4684e280fe..2fa03e0e65 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt
@@ -18,7 +18,6 @@ package im.vector.riotredesign.features.home.room.detail.timeline.factory
 
 import android.text.Spannable
 import android.text.SpannableStringBuilder
-import android.text.util.Linkify
 import im.vector.matrix.android.api.permalinks.MatrixLinkify
 import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
 import im.vector.matrix.android.api.session.events.model.EventType
@@ -28,6 +27,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
 import im.vector.riotredesign.R
 import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
 import im.vector.riotredesign.core.extensions.localDateTime
+import im.vector.riotredesign.core.linkify.VectorLinkify
 import im.vector.riotredesign.core.resources.ColorProvider
 import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
 import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
@@ -68,11 +68,11 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
         val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation)
 
         return when (messageContent) {
-            is MessageTextContent   -> buildTextMessageItem(messageContent, informationData, callback)
-            is MessageImageContent  -> buildImageMessageItem(messageContent, informationData, callback)
-            is MessageEmoteContent  -> buildEmoteMessageItem(messageContent, informationData, callback)
+            is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
+            is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
+            is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
             is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback)
-            else                    -> buildNotHandledMessageItem(messageContent)
+            else -> buildNotHandledMessageItem(messageContent)
         }
     }
 
@@ -155,7 +155,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
                 callback?.onUrlClicked(url)
             }
         })
-        Linkify.addLinks(spannable, Linkify.ALL)
+        VectorLinkify.addLinks(spannable, true)
         return spannable
     }