Create new linkifier.

Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
Greyson Parrelli
2026-05-12 10:05:38 -04:00
committed by GitHub
parent f4bd5fbe8b
commit 5655fcf973
13 changed files with 937 additions and 74 deletions
@@ -14,6 +14,7 @@ import androidx.core.text.util.LinkifyCompat
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan
import org.thoughtcrime.securesms.util.LinkUtil
import org.thoughtcrime.securesms.util.Linkification
import org.thoughtcrime.securesms.util.UrlClickHandler
import org.thoughtcrime.securesms.util.hasOnlyThumbnail
@@ -28,24 +29,21 @@ object V2ConversationItemUtils {
@JvmStatic
fun linkifyUrlLinks(messageBody: Spannable, shouldLinkifyAllLinks: Boolean, urlClickHandler: UrlClickHandler) {
val linkPattern = Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS
val hasLinks = LinkifyCompat.addLinks(messageBody, if (shouldLinkifyAllLinks) linkPattern else 0)
if (!hasLinks) {
if (!shouldLinkifyAllLinks) {
return
}
messageBody.getSpans(0, messageBody.length, URLSpan::class.java)
.filterNot { LinkUtil.isLegalUrl(it.url) }
.forEach(messageBody::removeSpan)
LinkifyCompat.addLinks(messageBody, Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS)
Linkification.applyWebUrlSpans(messageBody)
messageBody.getSpans(0, messageBody.length, URLSpan::class.java).forEach { urlSpan ->
val url = urlSpan.url
val start = messageBody.getSpanStart(urlSpan)
val end = messageBody.getSpanEnd(urlSpan)
val span = InterceptableLongClickCopyLinkSpan(urlSpan.url, urlClickHandler)
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
messageBody.removeSpan(urlSpan)
if (LinkUtil.isLegalUrl(url)) {
messageBody.setSpan(InterceptableLongClickCopyLinkSpan(url, urlClickHandler), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
}
@@ -15,11 +15,10 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.util.LinkifyCompat;
import java.util.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.util.LinkUtil;
import org.thoughtcrime.securesms.util.Linkification;
import org.thoughtcrime.securesms.util.LongClickCopySpan;
public final class GroupDescriptionUtil {
@@ -38,21 +37,16 @@ public final class GroupDescriptionUtil {
SpannableString descriptionSpannable = new SpannableString(scrubbedDescription);
if (linkify) {
int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS;
boolean hasLinks = LinkifyCompat.addLinks(descriptionSpannable, linkPattern);
LinkifyCompat.addLinks(descriptionSpannable, Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS);
Linkification.applyWebUrlSpans(descriptionSpannable);
if (hasLinks) {
Stream.of(descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class))
.filter(url -> !LinkUtil.isLegalUrl(url.getURL()))
.forEach(descriptionSpannable::removeSpan);
URLSpan[] urlSpans = descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class);
for (URLSpan urlSpan : urlSpans) {
int start = descriptionSpannable.getSpanStart(urlSpan);
int end = descriptionSpannable.getSpanEnd(urlSpan);
URLSpan span = new LongClickCopySpan(urlSpan.getURL());
descriptionSpannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
for (URLSpan urlSpan : descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class)) {
String url = urlSpan.getURL();
int start = descriptionSpannable.getSpanStart(urlSpan);
int end = descriptionSpannable.getSpanEnd(urlSpan);
descriptionSpannable.removeSpan(urlSpan);
if (LinkUtil.isLegalUrl(url)) {
descriptionSpannable.setSpan(new LongClickCopySpan(url), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}
@@ -1,19 +1,17 @@
package org.thoughtcrime.securesms.linkpreview;
import android.annotation.SuppressLint;
import android.text.SpannableString;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import androidx.core.text.util.LinkifyCompat;
import java.util.stream.Collectors;
import org.signal.core.util.Linkifier;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.LinkUtil;
import org.thoughtcrime.securesms.util.Linkification;
import org.signal.core.util.Util;
import org.whispersystems.signalservice.api.util.OptionalUtil;
@@ -53,16 +51,15 @@ public final class LinkPreviewUtil {
* @return All URLs allowed as previews in the source text.
*/
public static @NonNull Links findValidPreviewUrls(@NonNull String text) {
SpannableString spannable = new SpannableString(text);
boolean found = LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS);
if (!found) {
List<Linkifier.DetectedLink> detected = Linkification.findWebLinks(text);
if (detected.isEmpty()) {
return Links.EMPTY;
}
return new Links(Stream.of(spannable.getSpans(0, spannable.length(), URLSpan.class))
.map(span -> new Link(span.getURL(), spannable.getSpanStart(span)))
.filter(link -> LinkUtil.isValidPreviewUrl(link.url)).collect(Collectors.toList()));
return new Links(detected.stream()
.map(d -> new Link(d.getUrl(), d.getStart()))
.filter(link -> LinkUtil.isValidPreviewUrl(link.url))
.collect(Collectors.toList()));
}
public static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html) {
@@ -7,8 +7,6 @@ import android.net.Uri;
import android.os.Bundle;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
@@ -23,11 +21,11 @@ import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat;
import androidx.core.app.ShareCompat;
import androidx.core.content.ContextCompat;
import androidx.core.text.util.LinkifyCompat;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.Linkifier;
import org.signal.core.util.ThreadUtil;
import org.signal.debuglogsviewer.DebugLogsViewer;
import org.thoughtcrime.securesms.BaseActivity;
@@ -36,6 +34,7 @@ import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
import org.thoughtcrime.securesms.components.ProgressCard;
import org.thoughtcrime.securesms.components.SearchView;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Linkification;
import org.thoughtcrime.securesms.util.LongClickCopySpan;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.signal.core.ui.util.ThemeUtil;
@@ -449,15 +448,8 @@ public class SubmitDebugLogActivity extends BaseActivity {
TextView dialogView = new TextView(builder.getContext());
LongClickCopySpan longClickUrl = new LongClickCopySpan(url);
LinkifyCompat.addLinks(spannableDialogText, Linkify.WEB_URLS);
URLSpan[] spans = spannableDialogText.getSpans(0, spannableDialogText.length(), URLSpan.class);
for (URLSpan span : spans) {
int start = spannableDialogText.getSpanStart(span);
int end = spannableDialogText.getSpanEnd(span);
spannableDialogText.setSpan(longClickUrl, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
for (Linkifier.DetectedLink link : Linkification.findWebLinks(dialogText)) {
spannableDialogText.setSpan(longClickUrl, link.getStart(), link.getEnd(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
dialogView.setText(spannableDialogText);
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.nicknames
import android.os.Bundle
import android.text.SpannableString
import android.text.util.Linkify
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.layout.Column
@@ -40,6 +41,7 @@ import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Linkification
import org.thoughtcrime.securesms.util.viewModel
import org.signal.core.ui.R as CoreUiR
@@ -142,11 +144,7 @@ private fun ViewNoteBottomSheetContent(
)
)
val mask = if (LocalInspectionMode.current) {
Linkify.WEB_URLS
} else {
Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS
}
val isInspection = LocalInspectionMode.current
AndroidView(
factory = { context ->
@@ -161,9 +159,12 @@ private fun ViewNoteBottomSheetContent(
.fillMaxWidth()
.padding(bottom = 48.dp)
) {
it.text = note
LinkifyCompat.addLinks(it, mask)
val spannable = SpannableString(note)
if (!isInspection) {
LinkifyCompat.addLinks(spannable, Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS)
}
Linkification.applyWebUrlSpans(spannable)
it.text = spannable
}
}
}
@@ -94,6 +94,7 @@ import org.thoughtcrime.securesms.util.AvatarUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.LinkUtil
import org.thoughtcrime.securesms.util.Linkification
import org.thoughtcrime.securesms.util.LongClickCopySpan
import org.thoughtcrime.securesms.util.LongClickMovementMethod
import org.thoughtcrime.securesms.util.Projection
@@ -993,20 +994,16 @@ class StoryViewerPageFragment :
}
fun linkifyUrlLinks(spannable: Spannable) {
val hasLinks = LinkifyCompat.addLinks(spannable, CAPTION_LINK_PATTERN)
LinkifyCompat.addLinks(spannable, Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS)
Linkification.applyWebUrlSpans(spannable)
if (hasLinks) {
spannable.getSpans(0, spannable.length, URLSpan::class.java)
.filterNot { url -> LinkUtil.isLegalUrl(url.url) }
.forEach { spannable.removeSpan(it) }
val urlSpans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
for (urlSpan in urlSpans) {
val start = spannable.getSpanStart(urlSpan)
val end = spannable.getSpanEnd(urlSpan)
val span = LongClickCopySpan(urlSpan.url)
spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.getSpans(0, spannable.length, URLSpan::class.java).forEach { urlSpan ->
val url = urlSpan.url
val start = spannable.getSpanStart(urlSpan)
val end = spannable.getSpanEnd(urlSpan)
spannable.removeSpan(urlSpan)
if (LinkUtil.isLegalUrl(url)) {
spannable.setSpan(LongClickCopySpan(url), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
@@ -1315,7 +1312,6 @@ class StoryViewerPageFragment :
private val ONBOARDING_DURATION = TimeUnit.SECONDS.toMillis(10)
private const val SMALL_CAPTION_TEXT_MAX_LENGTH = 280
private const val SMALL_CAPTION_TEXT_MAX_LINES = 5
private const val CAPTION_LINK_PATTERN = Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS
private const val ARGS = "args"
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util
import android.text.Spannable
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.core.text.util.LinkifyCompat
import org.signal.core.util.Linkifier
import org.signal.core.util.Linkifier.DetectedLink
import org.signal.core.util.addDetectedLinks
/**
* Temporary abstraction while we switch over to the new [Linkifier] via remote config.
* When the remote config is off, we fallback to the pre-existing android link logic.
*/
object Linkification {
/**
* Adds [URLSpan]s for web URLs in [spannable]. Returns `true` if at least one span was added.
*/
@JvmStatic
fun applyWebUrlSpans(spannable: Spannable): Boolean {
return if (RemoteConfig.useNewLinkifier) {
spannable.addDetectedLinks()
} else {
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS)
}
}
/**
* Finds web URLs in [text].
*/
@JvmStatic
fun findWebLinks(text: CharSequence): List<DetectedLink> {
if (RemoteConfig.useNewLinkifier) {
return Linkifier.findLinks(text)
}
val spannable = SpannableString(text)
if (!LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS)) {
return emptyList()
}
return spannable.getSpans(0, spannable.length, URLSpan::class.java).map { span ->
DetectedLink(
start = spannable.getSpanStart(span),
end = spannable.getSpanEnd(span),
url = span.url
)
}
}
}
@@ -1,8 +1,8 @@
package org.thoughtcrime.securesms.util
/**
* LinkifyCompat.addLinks() will strip pre-existing URLSpans. This acts as a way to
* indicate where a link should be added without being stripped. The consumer is
* responsible for replacing the placeholder with an actual URLSpan.
* Marks a region where a link should later be applied, without storing it as an actual [URLSpan].
* The consumer is responsible for replacing the placeholder with an appropriate URLSpan when the
* text is rendered.
*/
class PlaceholderURLSpan(url: String) : android.text.Annotation("placeholderUrl", url)
@@ -1365,5 +1365,16 @@ object RemoteConfig {
hotSwappable = true
)
/**
* Whether to use our custom [org.signal.core.util.Linkifier] for web URL detection.
*/
@JvmStatic
@get:JvmName("useNewLinkifier")
val useNewLinkifier: Boolean by remoteBoolean(
key = "android.useNewLinkifier",
defaultValue = false,
hotSwappable = true
)
// endregion
}