Yuito-app-android/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt

404 lines
15 KiB
Kotlin

/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
@file:JvmName("LinkHelper")
package com.keylesspalace.tusky.util
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.QuoteSpan
import android.text.style.URLSpan
import android.util.Log
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_UP
import android.view.View
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import androidx.core.text.getSpans
import androidx.preference.PreferenceManager
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status.Mention
import com.keylesspalace.tusky.interfaces.LinkListener
import java.lang.ref.WeakReference
import java.net.URI
import java.net.URISyntaxException
fun getDomain(urlString: String?): String {
val host = urlString?.toUri()?.host
return when {
host == null -> ""
host.startsWith("www.") -> host.substring(4)
else -> host
}
}
/**
* Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating
* them with callbacks to notify when they're clicked.
*
* @param view the returned text will be put in
* @param content containing text with mentions, links, or hashtags
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
fun setClickableText(view: TextView, content: CharSequence, mentions: List<Mention>, tags: List<HashTag>?, listener: LinkListener) {
val spannableContent = markupHiddenUrls(view, content)
view.text = spannableContent.apply {
styleQuoteSpans(view)
getSpans(0, spannableContent.length, URLSpan::class.java).forEach { span ->
setClickableText(span, this, mentions, tags, listener)
}
}
view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance()
}
@VisibleForTesting
fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuilder {
val spannableContent = SpannableStringBuilder(content)
val originalSpans = spannableContent.getSpans(0, content.length, URLSpan::class.java)
val obscuredLinkSpans = originalSpans.filter {
val start = spannableContent.getSpanStart(it)
val firstCharacter = content[start]
return@filter if (firstCharacter == '#' || firstCharacter == '@') {
false
} else {
val text = spannableContent.subSequence(start, spannableContent.getSpanEnd(it)).toString()
.split(' ').lastOrNull().orEmpty()
var textDomain = getDomain(text)
if (textDomain.isBlank()) {
textDomain = getDomain("https://$text")
}
getDomain(it.url) != textDomain
}
}
for (span in obscuredLinkSpans) {
val start = spannableContent.getSpanStart(span)
val end = spannableContent.getSpanEnd(span)
val originalText = spannableContent.subSequence(start, end)
val replacementText = view.context.getString(R.string.url_domain_notifier, originalText, getDomain(span.url))
spannableContent.replace(start, end, replacementText) // this also updates the span locations
val linkDrawable = AppCompatResources.getDrawable(view.context, R.drawable.ic_link)!!
// ImageSpan does not always align the icon correctly in the line, let's use our custom emoji span for this
val linkDrawableSpan = EmojiSpan(WeakReference(view))
linkDrawableSpan.imageDrawable = linkDrawable
val placeholderIndex = replacementText.indexOf("🔗")
spannableContent.setSpan(
linkDrawableSpan,
start + placeholderIndex,
start + placeholderIndex + "🔗".length,
0
)
}
return spannableContent
}
@VisibleForTesting
fun setClickableText(
span: URLSpan,
builder: SpannableStringBuilder,
mentions: List<Mention>,
tags: List<HashTag>?,
listener: LinkListener
) = builder.apply {
val start = getSpanStart(span)
val end = getSpanEnd(span)
val flags = getSpanFlags(span)
val text = subSequence(start, end)
val customSpan = when (text[0]) {
'#' -> getCustomSpanForTag(text, tags, span, listener)
'@' -> getCustomSpanForMention(mentions, span, listener)
else -> null
} ?: object : NoUnderlineURLSpan(span.url) {
override fun onClick(view: View) = listener.onViewUrl(url)
}
removeSpan(span)
setSpan(customSpan, start, end, flags)
}
@VisibleForTesting
fun getTagName(text: CharSequence, tags: List<HashTag>?): String? {
val scrapedName = normalizeToASCII(text.subSequence(1, text.length)).toString()
return when (tags) {
null -> scrapedName
else -> tags.firstOrNull { it.name.equals(scrapedName, true) }?.name
}
}
private fun getCustomSpanForTag(text: CharSequence, tags: List<HashTag>?, span: URLSpan, listener: LinkListener): ClickableSpan? {
return getTagName(text, tags)?.let {
object : NoUnderlineURLSpan(span.url) {
override fun onClick(view: View) = listener.onViewTag(it)
}
}
}
private fun getCustomSpanForMention(mentions: List<Mention>, span: URLSpan, listener: LinkListener): ClickableSpan? {
// https://github.com/tuskyapp/Tusky/pull/2339
return mentions.firstOrNull { it.url == span.url }?.let {
getCustomSpanForMentionUrl(span.url, it.id, listener)
}
}
private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: LinkListener): ClickableSpan {
return object : MentionSpan(url) {
override fun onClick(view: View) = listener.onViewAccount(mentionId)
}
}
private fun SpannableStringBuilder.styleQuoteSpans(view: TextView) {
getSpans(0, length, QuoteSpan::class.java).forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
val flags = getSpanFlags(span)
val quoteColor = MaterialColors.getColor(view, android.R.attr.textColorTertiary)
val newQuoteSpan = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
QuoteSpan(
quoteColor,
Utils.dpToPx(view.context, 3),
Utils.dpToPx(view.context, 8)
)
} else {
QuoteSpan(quoteColor)
}
val quoteColorSpan = ForegroundColorSpan(quoteColor)
removeSpan(span)
setSpan(newQuoteSpan, start, end, flags)
setSpan(quoteColorSpan, start, end, flags)
}
}
/**
* Put mentions in a piece of text and makes them clickable, associating them with callbacks to
* notify when they're clicked.
*
* @param view the returned text will be put in
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
fun setClickableMentions(view: TextView, mentions: List<Mention>?, listener: LinkListener) {
if (mentions?.isEmpty() != false) {
view.text = null
return
}
view.text = SpannableStringBuilder().apply {
var start = 0
var end = 0
var flags: Int
var firstMention = true
for (mention in mentions) {
val customSpan = getCustomSpanForMentionUrl(mention.url, mention.id, listener)
end += 1 + mention.localUsername.length // length of @ + username
flags = getSpanFlags(customSpan)
if (firstMention) {
firstMention = false
} else {
append(" ")
start += 1
end += 1
}
append("@")
append(mention.localUsername)
setSpan(customSpan, start, end, flags)
start = end
}
}
view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance()
}
fun createClickableText(text: String, link: String): CharSequence {
return SpannableStringBuilder(text).apply {
setSpan(NoUnderlineURLSpan(link), 0, text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
}
/**
* Opens a link, depending on the settings, either in the browser or in a custom tab
*
* @receiver the Context to open the link from
* @param url a string containing the url to open
*/
fun Context.openLink(url: String) {
val uri = url.toUri().normalizeScheme()
val useCustomTabs = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("customTabs", false)
if (useCustomTabs) {
openLinkInCustomTab(uri, this)
} else {
openLinkInBrowser(uri, this)
}
}
/**
* opens a link in the browser via Intent.ACTION_VIEW
*
* @param uri the uri to open
* @param context context
*/
private fun openLinkInBrowser(uri: Uri?, context: Context) {
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent, $intent")
}
}
/**
* tries to open a link in a custom tab
* falls back to browser if not possible
*
* @param uri the uri to open
* @param context context
*/
fun openLinkInCustomTab(uri: Uri, context: Context) {
val toolbarColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)
val navigationbarColor = MaterialColors.getColor(context, android.R.attr.navigationBarColor, Color.BLACK)
val navigationbarDividerColor = MaterialColors.getColor(context, R.attr.dividerColor, Color.BLACK)
val colorSchemeParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.setShowTitle(true)
.build()
try {
customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent $customTabsIntent")
openLinkInBrowser(uri, context)
}
}
// https://mastodon.foo.bar/@User
// https://mastodon.foo.bar/@User/43456787654678
// https://mastodon.foo.bar/users/User/statuses/43456787654678
// https://pleroma.foo.bar/users/User
// https://pleroma.foo.bar/users/9qTHT2ANWUdXzENqC0
// https://pleroma.foo.bar/notice/9sBHWIlwwGZi5QGlHc
// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207
// https://friendica.foo.bar/profile/user
// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207
// https://misskey.foo.bar/notes/83w6r388br (always lowercase)
// https://pixelfed.social/p/connyduck/391263492998670833
// https://pixelfed.social/connyduck
// https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2
// https://gts.foo.bar/@goblin
// https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5
// https://bookwyrm.foo.bar/user/User
// https://bookwyrm.foo.bar/user/User/comment/123456
fun looksLikeMastodonUrl(urlString: String): Boolean {
val uri: URI
try {
uri = URI(urlString)
} catch (e: URISyntaxException) {
return false
}
if (uri.query != null ||
uri.fragment != null ||
uri.path == null
) {
return false
}
return uri.path.let {
it.matches("^/@[^/]+$".toRegex()) ||
it.matches("^/@[^/]+/\\d+$".toRegex()) ||
it.matches("^/users/[^/]+/statuses/\\d+$".toRegex()) ||
it.matches("^/users/\\w+$".toRegex()) ||
it.matches("^/user/[^/]+/comment/\\d+$".toRegex()) ||
it.matches("^/user/\\w+$".toRegex()) ||
it.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) ||
it.matches("^/objects/[-a-f0-9]+$".toRegex()) ||
it.matches("^/notes/[a-z0-9]+$".toRegex()) ||
it.matches("^/display/[-a-f0-9]+$".toRegex()) ||
it.matches("^/profile/\\w+$".toRegex()) ||
it.matches("^/p/\\w+/\\d+$".toRegex()) ||
it.matches("^/\\w+$".toRegex()) ||
it.matches("^/@[^/]+/statuses/[a-zA-Z0-9]+$".toRegex()) ||
it.matches("^/o/[a-f0-9]+$".toRegex())
}
}
private const val TAG = "LinkHelper"
/**
* [LinkMovementMethod] that doesn't add a leading/trailing clickable area.
*
* [LinkMovementMethod] has a bug in its calculation of the clickable width of a span on a line. If
* the span is the last thing on the line the clickable area extends to the end of the view. So the
* user can tap what appears to be whitespace and open a link.
*
* Fix this by overriding ACTION_UP touch events and calculating the true start and end of the
* content on the line that was tapped. Then ignore clicks that are outside this area.
*
* See https://github.com/tuskyapp/Tusky/issues/1567.
*/
object NoTrailingSpaceLinkMovementMethod : LinkMovementMethod() {
override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean {
val action = event.action
if (action != ACTION_UP) return super.onTouchEvent(widget, buffer, event)
val x = event.x.toInt()
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
val line = widget.layout.getLineForVertical(y)
val lineLeft = widget.layout.getLineLeft(line)
val lineRight = widget.layout.getLineRight(line)
if (x > lineRight || x >= 0 && x < lineLeft) {
return true
}
return super.onTouchEvent(widget, buffer, event)
}
fun getInstance() = NoTrailingSpaceLinkMovementMethod
}