From 347967700b5d34d2c82a2b033a350d79f2cc7f32 Mon Sep 17 00:00:00 2001 From: Benoit Marty 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() + + 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) { + 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 { (_, 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 }