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 #/@
This commit is contained in:
Levi Bard 2022-09-17 19:06:07 +02:00 committed by GitHub
parent c908ebb3f1
commit 687cffd540
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 123 additions and 1 deletions

View File

@ -57,7 +57,9 @@ fun getDomain(urlString: String?): String {
* @param listener to notify about particular spans that are clicked * @param listener to notify about particular spans that are clicked
*/ */
fun setClickableText(view: TextView, content: CharSequence, mentions: List<Mention>, tags: List<HashTag>?, listener: LinkListener) { fun setClickableText(view: TextView, content: CharSequence, mentions: List<Mention>, tags: List<HashTag>?, 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 { getSpans(0, content.length, URLSpan::class.java).forEach {
setClickableText(it, this, mentions, tags, listener) setClickableText(it, this, mentions, tags, listener)
} }
@ -65,6 +67,29 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List<Menti
view.movementMethod = LinkMovementMethod.getInstance() view.movementMethod = LinkMovementMethod.getInstance()
} }
@VisibleForTesting
fun markupHiddenUrls(context: Context, content: CharSequence, mentions: List<Mention>, tags: List<HashTag>?, 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 @VisibleForTesting
fun setClickableText( fun setClickableText(
span: URLSpan, span: URLSpan,

View File

@ -658,6 +658,7 @@
<string name="tips_push_notification_migration">Re-login all accounts to enable push notification support.</string> <string name="tips_push_notification_migration">Re-login all accounts to enable push notification support.</string>
<string name="dialog_push_notification_migration">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.</string> <string name="dialog_push_notification_migration">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.</string>
<string name="dialog_push_notification_migration_other_accounts">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.</string> <string name="dialog_push_notification_migration_other_accounts">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.</string>
<string name="url_domain_notifier">%s (🔗 %s)</string>
</resources> </resources>

View File

@ -1,8 +1,11 @@
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import android.content.Context
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.style.URLSpan import android.text.style.URLSpan
import androidx.test.ext.junit.runners.AndroidJUnit4 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.HashTag
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
@ -29,6 +32,9 @@ class LinkHelperTest {
HashTag("mastodev", "https://example.com/Tags/mastodev"), HashTag("mastodev", "https://example.com/Tags/mastodev"),
) )
private val context: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext
@Test @Test
fun whenSettingClickableText_mentionUrlsArePreserved() { fun whenSettingClickableText_mentionUrlsArePreserved() {
val builder = SpannableStringBuilder() val builder = SpannableStringBuilder()
@ -140,4 +146,94 @@ class LinkHelperTest {
Assert.assertEquals(domain, getDomain(url)) 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)})"))
}
}
} }