From 687cffd5405533aa2dd36c01a4bbd154ae16040e Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Sat, 17 Sep 2022 19:06:07 +0200 Subject: [PATCH] Show target domains for non-mention/non-hashtag links where the target domain is not provided or differs from the domain in the text (#2698) * Show target domains for non-mention/non-hashtag links where the target domain is not provided or differs from the domain in the text. Addresses #2694 * Add link signifier to the marked-up domain * Back down on validating hashtags and mentions, don't markup _any_ urls where the text starts with #/@ --- .../keylesspalace/tusky/util/LinkHelper.kt | 27 +++++- app/src/main/res/values/strings.xml | 1 + .../tusky/util/LinkHelperTest.kt | 96 +++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index 1abbab14d..442a65513 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -57,7 +57,9 @@ fun getDomain(urlString: String?): String { * @param listener to notify about particular spans that are clicked */ fun setClickableText(view: TextView, content: CharSequence, mentions: List, tags: List?, listener: LinkListener) { - view.text = SpannableStringBuilder.valueOf(content).apply { + val spannableContent = markupHiddenUrls(view.context, content, mentions, tags, listener) + + view.text = spannableContent.apply { getSpans(0, content.length, URLSpan::class.java).forEach { setClickableText(it, this, mentions, tags, listener) } @@ -65,6 +67,29 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List, tags: List?, listener: LinkListener): SpannableStringBuilder { + val spannableContent = SpannableStringBuilder.valueOf(content) + val originalSpans = spannableContent.getSpans(0, content.length, URLSpan::class.java) + val obscuredLinkSpans = originalSpans.filter { + val text = spannableContent.subSequence(spannableContent.getSpanStart(it), spannableContent.getSpanEnd(it)) + val firstCharacter = text[0] + firstCharacter != '#' && + firstCharacter != '@' && + getDomain(text.toString()) != getDomain(it.url) + } + + for (span in obscuredLinkSpans) { + val start = spannableContent.getSpanStart(span) + val end = spannableContent.getSpanEnd(span) + val originalText = spannableContent.subSequence(start, end) + val replacementText = context.getString(R.string.url_domain_notifier, originalText, getDomain(span.url)) + spannableContent.replace(start, end, replacementText) // this also updates the span locations + } + + return spannableContent +} + @VisibleForTesting fun setClickableText( span: URLSpan, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66f5a9b66..5b0e3f0c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -658,6 +658,7 @@ Re-login all accounts to enable push notification support. In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in Account Preferences will preserve all of your local drafts and cache. You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support. + %s (🔗 %s) diff --git a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt index 4a2cdc53f..3bd2311d8 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt @@ -1,8 +1,11 @@ package com.keylesspalace.tusky.util +import android.content.Context import android.text.SpannableStringBuilder import android.text.style.URLSpan import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener @@ -29,6 +32,9 @@ class LinkHelperTest { HashTag("mastodev", "https://example.com/Tags/mastodev"), ) + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + @Test fun whenSettingClickableText_mentionUrlsArePreserved() { val builder = SpannableStringBuilder() @@ -140,4 +146,94 @@ class LinkHelperTest { Assert.assertEquals(domain, getDomain(url)) } } + + @Test + fun hiddenDomainsAreMarkedUp() { + val displayedContent = "This is a good place to go" + val maliciousDomain = "malicious.place" + val maliciousUrl = "https://$maliciousDomain/to/go" + val content = SpannableStringBuilder() + content.append(displayedContent, URLSpan(maliciousUrl), 0) + Assert.assertEquals(context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain), markupHiddenUrls(context, content, listOf(), listOf(), listener).toString()) + } + + @Test + fun fraudulentDomainsAreMarkedUp() { + val displayedContent = "https://tusky.app/" + val maliciousDomain = "malicious.place" + val maliciousUrl = "https://$maliciousDomain/to/go" + val content = SpannableStringBuilder() + content.append(displayedContent, URLSpan(maliciousUrl), 0) + Assert.assertEquals(context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain), markupHiddenUrls(context, content, listOf(), listOf(), listener).toString()) + } + + @Test fun multipleHiddenDomainsAreMarkedUp() { + val domains = listOf("one.place", "another.place", "athird.place") + val displayedContent = "link" + val content = SpannableStringBuilder() + for (domain in domains) { + content.append(displayedContent, URLSpan("https://$domain/foo/bar"), 0) + } + + val markedUpContent = markupHiddenUrls(context, content, listOf(), listOf(), listener) + for (domain in domains) { + Assert.assertTrue(markedUpContent.contains(context.getString(R.string.url_domain_notifier, displayedContent, domain))) + } + } + + @Test + fun validMentionsAreNotMarkedUp() { + val builder = SpannableStringBuilder() + for (mention in mentions) { + builder.append("@${mention.username}", URLSpan(mention.url), 0) + builder.append(" ") + } + + val markedUpContent = markupHiddenUrls(context, builder, mentions, tags, listener) + for (mention in mentions) { + Assert.assertFalse(markedUpContent.contains("${getDomain(mention.url)})")) + } + } + + @Test + fun invalidMentionsAreNotMarkedUp() { + val builder = SpannableStringBuilder() + for (mention in mentions) { + builder.append("@${mention.username}", URLSpan(mention.url), 0) + builder.append(" ") + } + + val markedUpContent = markupHiddenUrls(context, builder, listOf(), tags, listener) + for (mention in mentions) { + Assert.assertFalse(markedUpContent.contains("${getDomain(mention.url)})")) + } + } + + @Test + fun validTagsAreNotMarkedUp() { + val builder = SpannableStringBuilder() + for (tag in tags) { + builder.append("#${tag.name}", URLSpan(tag.url), 0) + builder.append(" ") + } + + val markedUpContent = markupHiddenUrls(context, builder, mentions, tags, listener) + for (tag in tags) { + Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})")) + } + } + + @Test + fun invalidTagsAreNotMarkedUp() { + val builder = SpannableStringBuilder() + for (tag in tags) { + builder.append("#${tag.name}", URLSpan(tag.url), 0) + builder.append(" ") + } + + val markedUpContent = markupHiddenUrls(context, builder, mentions, listOf(), listener) + for (tag in tags) { + Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})")) + } + } }