Generalize url scheme parsing/highlighting (#1008)
* Add support for highlighting dat, ssb, ipfs url schemes. #847 * Generalize scheme parsing for url highlighting. #847 * Migrate LinkHelper to kotlinmain
parent
021a5112b9
commit
d54599a570
@ -1,241 +0,0 @@ |
|||||||
/* 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>. */
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky.util; |
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException; |
|
||||||
import android.content.Context; |
|
||||||
import android.content.Intent; |
|
||||||
import android.net.Uri; |
|
||||||
import android.preference.PreferenceManager; |
|
||||||
import androidx.annotation.Nullable; |
|
||||||
import androidx.browser.customtabs.CustomTabsIntent; |
|
||||||
import android.text.SpannableStringBuilder; |
|
||||||
import android.text.Spanned; |
|
||||||
import android.text.method.LinkMovementMethod; |
|
||||||
import android.text.style.ClickableSpan; |
|
||||||
import android.text.style.URLSpan; |
|
||||||
import android.util.Log; |
|
||||||
import android.view.View; |
|
||||||
import android.widget.TextView; |
|
||||||
|
|
||||||
import com.keylesspalace.tusky.entity.Status; |
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener; |
|
||||||
|
|
||||||
import java.lang.CharSequence; |
|
||||||
import java.net.URI; |
|
||||||
import java.net.URISyntaxException; |
|
||||||
|
|
||||||
public class LinkHelper { |
|
||||||
private static String getDomain(String urlString) { |
|
||||||
URI uri; |
|
||||||
try { |
|
||||||
uri = new URI(urlString); |
|
||||||
} catch (URISyntaxException e) { |
|
||||||
return ""; |
|
||||||
} |
|
||||||
String host = uri.getHost(); |
|
||||||
if (host.startsWith("www.")) { |
|
||||||
return host.substring(4); |
|
||||||
} else { |
|
||||||
return 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 |
|
||||||
*/ |
|
||||||
public static void setClickableText(TextView view, Spanned content, |
|
||||||
@Nullable Status.Mention[] mentions, final LinkListener listener) { |
|
||||||
SpannableStringBuilder builder = new SpannableStringBuilder(content); |
|
||||||
URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class); |
|
||||||
for (URLSpan span : urlSpans) { |
|
||||||
int start = builder.getSpanStart(span); |
|
||||||
int end = builder.getSpanEnd(span); |
|
||||||
int flags = builder.getSpanFlags(span); |
|
||||||
CharSequence text = builder.subSequence(start, end); |
|
||||||
ClickableSpan customSpan = null; |
|
||||||
|
|
||||||
if (text.charAt(0) == '#') { |
|
||||||
final String tag = text.subSequence(1, text.length()).toString(); |
|
||||||
customSpan = new ClickableSpanNoUnderline() { |
|
||||||
@Override |
|
||||||
public void onClick(View widget) { listener.onViewTag(tag); } |
|
||||||
}; |
|
||||||
} else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) { |
|
||||||
String accountUsername = text.subSequence(1, text.length()).toString(); |
|
||||||
/* There may be multiple matches for users on different instances with the same |
|
||||||
* username. If a match has the same domain we know it's for sure the same, but if |
|
||||||
* that can't be found then just go with whichever one matched last. */ |
|
||||||
String id = null; |
|
||||||
for (Status.Mention mention : mentions) { |
|
||||||
if (mention.getLocalUsername().equalsIgnoreCase(accountUsername)) { |
|
||||||
id = mention.getId(); |
|
||||||
if (mention.getUrl().contains(getDomain(span.getURL()))) { |
|
||||||
break; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
if (id != null) { |
|
||||||
final String accountId = id; |
|
||||||
customSpan = new ClickableSpanNoUnderline() { |
|
||||||
@Override |
|
||||||
public void onClick(View widget) { listener.onViewAccount(accountId); } |
|
||||||
}; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (customSpan == null) { |
|
||||||
customSpan = new CustomURLSpan(span.getURL()) { |
|
||||||
@Override |
|
||||||
public void onClick(View widget) { |
|
||||||
listener.onViewUrl(getURL()); |
|
||||||
} |
|
||||||
}; |
|
||||||
} |
|
||||||
builder.removeSpan(span); |
|
||||||
builder.setSpan(customSpan, start, end, flags); |
|
||||||
|
|
||||||
/* Add zero-width space after links in end of line to fix its too large hitbox. |
|
||||||
* See also : https://github.com/tuskyapp/Tusky/issues/846
|
|
||||||
* https://github.com/tuskyapp/Tusky/pull/916 */
|
|
||||||
if (end >= builder.length() || |
|
||||||
builder.subSequence(end, end + 1).toString().equals("\n")){ |
|
||||||
builder.insert(end, "\u200B"); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
view.setText(builder); |
|
||||||
view.setLinksClickable(true); |
|
||||||
view.setMovementMethod(LinkMovementMethod.getInstance()); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* 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 |
|
||||||
*/ |
|
||||||
public static void setClickableMentions( |
|
||||||
TextView view, @Nullable Status.Mention[] mentions, final LinkListener listener) { |
|
||||||
if (mentions == null || mentions.length == 0) { |
|
||||||
view.setText(null); |
|
||||||
return; |
|
||||||
} |
|
||||||
SpannableStringBuilder builder = new SpannableStringBuilder(); |
|
||||||
int start = 0; |
|
||||||
int end = 0; |
|
||||||
int flags; |
|
||||||
boolean firstMention = true; |
|
||||||
for (Status.Mention mention : mentions) { |
|
||||||
String accountUsername = mention.getLocalUsername(); |
|
||||||
final String accountId = mention.getId(); |
|
||||||
ClickableSpan customSpan = new ClickableSpanNoUnderline() { |
|
||||||
@Override |
|
||||||
public void onClick(View widget) { listener.onViewAccount(accountId); } |
|
||||||
}; |
|
||||||
|
|
||||||
end += 1 + accountUsername.length(); // length of @ + username
|
|
||||||
flags = builder.getSpanFlags(customSpan); |
|
||||||
if (firstMention) { |
|
||||||
firstMention = false; |
|
||||||
} else { |
|
||||||
builder.append(" "); |
|
||||||
start += 1; |
|
||||||
end += 1; |
|
||||||
} |
|
||||||
builder.append("@"); |
|
||||||
builder.append(accountUsername); |
|
||||||
builder.setSpan(customSpan, start, end, flags); |
|
||||||
builder.append("\u200B"); // same reasonning than in setClickableText
|
|
||||||
end += 1; // shift position to take the previous character into account
|
|
||||||
start = end; |
|
||||||
} |
|
||||||
view.setText(builder); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Opens a link, depending on the settings, either in the browser or in a custom tab |
|
||||||
* |
|
||||||
* @param url a string containing the url to open |
|
||||||
* @param context context |
|
||||||
*/ |
|
||||||
public static void openLink(String url, Context context) { |
|
||||||
Uri uri = Uri.parse(url).normalizeScheme(); |
|
||||||
|
|
||||||
boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context) |
|
||||||
.getBoolean("customTabs", false); |
|
||||||
if (useCustomTabs) { |
|
||||||
openLinkInCustomTab(uri, context); |
|
||||||
} else { |
|
||||||
openLinkInBrowser(uri, context); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* opens a link in the browser via Intent.ACTION_VIEW |
|
||||||
* |
|
||||||
* @param uri the uri to open |
|
||||||
* @param context context |
|
||||||
*/ |
|
||||||
public static void openLinkInBrowser(Uri uri, Context context) { |
|
||||||
Intent intent = new Intent(Intent.ACTION_VIEW, uri); |
|
||||||
try { |
|
||||||
context.startActivity(intent); |
|
||||||
} catch (ActivityNotFoundException e) { |
|
||||||
Log.w("URLSpan", "Actvity was not found for intent, " + intent.toString()); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* 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 |
|
||||||
*/ |
|
||||||
public static void openLinkInCustomTab(Uri uri, Context context) { |
|
||||||
int toolbarColor = ThemeUtils.getColorById(context, "custom_tab_toolbar"); |
|
||||||
|
|
||||||
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); |
|
||||||
builder.setToolbarColor(toolbarColor); |
|
||||||
CustomTabsIntent customTabsIntent = builder.build(); |
|
||||||
try { |
|
||||||
String packageName = CustomTabsHelper.getPackageNameToUse(context); |
|
||||||
|
|
||||||
//If we cant find a package name, it means theres no browser that supports
|
|
||||||
//Chrome Custom Tabs installed. So, we fallback to the webview
|
|
||||||
if (packageName == null) { |
|
||||||
openLinkInBrowser(uri, context); |
|
||||||
} else { |
|
||||||
customTabsIntent.intent.setPackage(packageName); |
|
||||||
customTabsIntent.launchUrl(context, uri); |
|
||||||
} |
|
||||||
} catch (ActivityNotFoundException e) { |
|
||||||
Log.w("URLSpan", "Activity was not found for intent, " + customTabsIntent.toString()); |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
} |
|
@ -0,0 +1,262 @@ |
|||||||
|
/* 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.net.Uri |
||||||
|
import android.preference.PreferenceManager |
||||||
|
import android.text.ParcelableSpan |
||||||
|
import androidx.browser.customtabs.CustomTabsIntent |
||||||
|
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.URLSpan |
||||||
|
import android.util.Log |
||||||
|
import android.view.View |
||||||
|
import android.widget.TextView |
||||||
|
|
||||||
|
import com.keylesspalace.tusky.entity.Status |
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener |
||||||
|
|
||||||
|
import java.util.HashSet |
||||||
|
|
||||||
|
const val ZERO_WIDTH_SPACE = "\u200B" |
||||||
|
private const val TAG = "LinkHelper" |
||||||
|
|
||||||
|
/** |
||||||
|
* 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: Spanned, mentions: Array<Status.Mention>?, listener: LinkListener) { |
||||||
|
val builder = SpannableStringBuilder(content) |
||||||
|
highlightSpans(builder, view.linkTextColors.defaultColor) |
||||||
|
val urlSpans = builder.getSpans(0, content.length, URLSpan::class.java) |
||||||
|
for (span in urlSpans) { |
||||||
|
replaceSpan(builder, span, getLinkSpan(span.url, listener)) |
||||||
|
} |
||||||
|
|
||||||
|
val otherSpans = builder.getSpans(0, content.length, ForegroundColorSpan::class.java) |
||||||
|
val usedMentionIds = HashSet<String>() |
||||||
|
|
||||||
|
for (span in otherSpans) { |
||||||
|
val start = builder.getSpanStart(span) |
||||||
|
val end = builder.getSpanEnd(span) |
||||||
|
val text = builder.subSequence(start, end) |
||||||
|
|
||||||
|
val customSpan = when (text[0]) { |
||||||
|
'#' -> getTagSpan(text.substring(1), listener) |
||||||
|
'@' -> { |
||||||
|
if (!mentions.isNullOrEmpty()) { |
||||||
|
/* There may be multiple matches for users on different instances with the same |
||||||
|
* username. If a match has the same domain we know it's for sure the same, but if |
||||||
|
* that can't be found then just go with whichever one matched last. */ |
||||||
|
firstUnusedMention(text.substring(1), mentions, usedMentionIds)?.let { id -> |
||||||
|
usedMentionIds.add(id) |
||||||
|
getAccountSpan(id, listener) |
||||||
|
} |
||||||
|
} else { |
||||||
|
null |
||||||
|
} |
||||||
|
} |
||||||
|
else -> null |
||||||
|
} |
||||||
|
|
||||||
|
replaceSpan(builder, span, customSpan) |
||||||
|
} |
||||||
|
|
||||||
|
view.text = builder |
||||||
|
view.linksClickable = true |
||||||
|
view.movementMethod = LinkMovementMethod.getInstance() |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Replace a span within a spannable string builder |
||||||
|
* @param builder the builder to replace spans within |
||||||
|
* @param oldSpan the span to be replaced |
||||||
|
* @param newSpan the new span to be used |
||||||
|
*/ |
||||||
|
private fun replaceSpan(builder: SpannableStringBuilder, oldSpan: ParcelableSpan?, newSpan: ClickableSpan?) { |
||||||
|
val start = builder.getSpanStart(oldSpan) |
||||||
|
val end = builder.getSpanEnd(oldSpan) |
||||||
|
val flags = builder.getSpanFlags(oldSpan) |
||||||
|
|
||||||
|
builder.removeSpan(oldSpan) |
||||||
|
builder.setSpan(newSpan, start, end, flags) |
||||||
|
|
||||||
|
/* Add zero-width space after links in end of line to fix its too large hitbox. |
||||||
|
* See also : https://github.com/tuskyapp/Tusky/issues/846 |
||||||
|
* https://github.com/tuskyapp/Tusky/pull/916 */ |
||||||
|
if (end >= builder.length || builder[end] == '\n') { |
||||||
|
builder.insert(end, ZERO_WIDTH_SPACE) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the first account id with matching username from mentions that isn't contained in usedIds, |
||||||
|
* or the id of the last matching account, if all matching ids are already contained |
||||||
|
* @param username the username to match |
||||||
|
* @param mentions the mentions to search |
||||||
|
* @param usedIds the collection of ids already used |
||||||
|
*/ |
||||||
|
private fun firstUnusedMention(username: String, mentions: Array<Status.Mention>, usedIds: Collection<String>): String? { |
||||||
|
var id: String? = null |
||||||
|
for (mention in mentions) { |
||||||
|
if (mention.localUsername.equals(username, true)) { |
||||||
|
id = mention.id |
||||||
|
if (!usedIds.contains(id)) { |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return id |
||||||
|
} |
||||||
|
|
||||||
|
private fun getTagSpan(tag: String, listener: LinkListener): ClickableSpan { |
||||||
|
return object : ClickableSpanNoUnderline() { |
||||||
|
override fun onClick(widget: View) { |
||||||
|
listener.onViewTag(tag) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun getAccountSpan(id: String?, listener: LinkListener): ClickableSpan { |
||||||
|
return object : ClickableSpanNoUnderline() { |
||||||
|
override fun onClick(widget: View) { |
||||||
|
listener.onViewAccount(id) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun getLinkSpan(url: String, listener: LinkListener): ClickableSpan { |
||||||
|
return object: CustomURLSpan(url) { |
||||||
|
override fun onClick(widget: View?) { |
||||||
|
listener.onViewUrl(url) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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: Array<Status.Mention>?, listener: LinkListener) { |
||||||
|
if (mentions.isNullOrEmpty()) { |
||||||
|
view.text = null |
||||||
|
return |
||||||
|
} |
||||||
|
val builder = SpannableStringBuilder() |
||||||
|
var start = 0 |
||||||
|
var end = 0 |
||||||
|
var firstMention = true |
||||||
|
|
||||||
|
for (mention in mentions) { |
||||||
|
val accountUsername = mention.localUsername |
||||||
|
val customSpan = getAccountSpan(mention.id, listener) |
||||||
|
|
||||||
|
end += 1 + accountUsername!!.length // length of @ + username |
||||||
|
val flags = builder.getSpanFlags(customSpan) |
||||||
|
if (firstMention) { |
||||||
|
firstMention = false |
||||||
|
} else { |
||||||
|
builder.append(" ") |
||||||
|
start += 1 |
||||||
|
end += 1 |
||||||
|
} |
||||||
|
builder.append("@") |
||||||
|
builder.append(accountUsername) |
||||||
|
builder.setSpan(customSpan, start, end, flags) |
||||||
|
builder.append(ZERO_WIDTH_SPACE) // same reasoning as in setClickableText |
||||||
|
end += 1 // shift position to take the previous character into account |
||||||
|
start = end |
||||||
|
} |
||||||
|
view.text = builder |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Opens a link, depending on the settings, either in the browser or in a custom tab |
||||||
|
* |
||||||
|
* @param url a string containing the url to open |
||||||
|
* @param context context |
||||||
|
*/ |
||||||
|
fun openLink(url: String?, context: Context) { |
||||||
|
val uri = Uri.parse(url).normalizeScheme() |
||||||
|
|
||||||
|
val useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("customTabs", false) |
||||||
|
if (useCustomTabs) { |
||||||
|
openLinkInCustomTab(uri, context) |
||||||
|
} else { |
||||||
|
openLinkInBrowser(uri, context) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* opens a link in the browser via Intent.ACTION_VIEW |
||||||
|
* |
||||||
|
* @param uri the uri to open |
||||||
|
* @param context context |
||||||
|
*/ |
||||||
|
fun openLinkInBrowser(uri: Uri, context: Context) { |
||||||
|
val intent = Intent(Intent.ACTION_VIEW, uri) |
||||||
|
try { |
||||||
|
context.startActivity(intent) |
||||||
|
} catch (e: ActivityNotFoundException) { |
||||||
|
Log.w(TAG, "Actvity 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 = ThemeUtils.getColorById(context, "custom_tab_toolbar") |
||||||
|
|
||||||
|
val builder = CustomTabsIntent.Builder() |
||||||
|
builder.setToolbarColor(toolbarColor) |
||||||
|
val customTabsIntent = builder.build() |
||||||
|
try { |
||||||
|
val packageName = CustomTabsHelper.getPackageNameToUse(context) |
||||||
|
|
||||||
|
//If we cant find a package name, it means theres no browser that supports |
||||||
|
//Chrome Custom Tabs installed. So, we fallback to the webview |
||||||
|
if (packageName == null) { |
||||||
|
openLinkInBrowser(uri, context) |
||||||
|
} else { |
||||||
|
customTabsIntent.intent.setPackage(packageName) |
||||||
|
customTabsIntent.launchUrl(context, uri) |
||||||
|
} |
||||||
|
} catch (e: ActivityNotFoundException) { |
||||||
|
Log.w(TAG, "Activity was not found for intent, $customTabsIntent") |
||||||
|
} |
||||||
|
|
||||||
|
} |
Loading…
Reference in new issue