From f7cee7e69327a700b9026ab43f5e6abc386e0f2d Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 16 Feb 2026 10:45:50 +0800 Subject: [PATCH 1/7] Converted LinkPreviewUtil to kotlin, Updated link preview URL detection logic --- .../securesms/conversation/v2/Util.kt | 7 +- .../linkpreview/LinkPreviewRepository.kt | 4 +- .../linkpreview/LinkPreviewUtil.java | 239 ------------------ .../securesms/linkpreview/LinkPreviewUtil.kt | 216 ++++++++++++++++ .../net/ContentProxySafetyInterceptor.java | 2 +- 5 files changed, 225 insertions(+), 243 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt index cdde239e0a..3565e5d8b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt @@ -17,6 +17,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.text.Spannable +import android.text.SpannableString import android.text.style.URLSpan import org.nibor.autolink.LinkExtractor import org.nibor.autolink.LinkType @@ -42,8 +43,10 @@ object Util { /** * Uses autolink-java to detect URLs with better boundaries than Android Linkify, * and applies standard URLSpan spans only. + * + * Also returns TRUE if any links were created */ - fun Spannable.addUrlSpansWithAutolink() { + fun Spannable.addUrlSpansWithAutolink() : Boolean { // Remove any existing URLSpans first so we don't get overlapping links getSpans(0, length, URLSpan::class.java).forEach { removeSpan(it) } @@ -65,5 +68,7 @@ object Util { setSpan(URLSpan(url), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } + + return links.any() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.kt index 786eaede07..c3c5daf1b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.kt @@ -126,8 +126,8 @@ class LinkPreviewRepository { val body = bodyObj.string() val openGraph: OpenGraph = LinkPreviewUtil.parseOpenGraphFields(body) - val title: String? = openGraph.title - var imageUrl: String? = openGraph.imageUrl + val title: String? = openGraph.getTitle() + var imageUrl: String? = openGraph.getImageUrl() if (!imageUrl.isNullOrEmpty() && !LinkPreviewUtil.isValidMediaUrl(imageUrl)) { Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java deleted file mode 100644 index 396a9d4d55..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java +++ /dev/null @@ -1,239 +0,0 @@ -package org.thoughtcrime.securesms.linkpreview; - -import android.annotation.SuppressLint; -import android.os.Build; -import android.text.Html; -import android.text.SpannableString; -import android.text.TextUtils; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import okhttp3.HttpUrl; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.Log; - -public final class LinkPreviewUtil { - - private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$", Pattern.CASE_INSENSITIVE); - private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$", Pattern.CASE_INSENSITIVE); - private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$", Pattern.CASE_INSENSITIVE); - private static final Pattern OPEN_GRAPH_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*og:([^\"]+)\"[^>]*/?\\s*>", Pattern.CASE_INSENSITIVE); - private static final Pattern ARTICLE_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*article:([^\"]+)\"[^>]*/?\\s*>", Pattern.CASE_INSENSITIVE); - private static final Pattern OPEN_GRAPH_CONTENT_PATTERN = Pattern.compile("content\\s*=\\s*\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); - private static final Pattern TITLE_PATTERN = Pattern.compile("<\\s*title[^>]*>(.*)<\\s*/title[^>]*>", Pattern.CASE_INSENSITIVE); - private static final Pattern FAVICON_PATTERN = Pattern.compile("<\\s*link[^>]*rel\\s*=\\s*\".*icon.*\"[^>]*>", Pattern.CASE_INSENSITIVE); - private static final Pattern FAVICON_HREF_PATTERN = Pattern.compile("href\\s*=\\s*\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); - - /** - * @return All whitelisted URLs in the source text. - */ - public static @NonNull List findWhitelistedUrls(@NonNull String text) { - SpannableString spannable = new SpannableString(text); - boolean found = Linkify.addLinks(spannable, Linkify.WEB_URLS); - - if (!found) { - return Collections.emptyList(); - } - - URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class); - List links = new java.util.ArrayList<>(spans.length); - - for (URLSpan span : spans) { - Link link = new Link(span.getURL(), spannable.getSpanStart(span)); - if (isValidLinkUrl(link.getUrl())) { - links.add(link); - } - } - - return links; - } - - /** - * @return True if the host is valid. - */ - public static boolean isValidLinkUrl(@Nullable String linkUrl) { - if (linkUrl == null) return false; - - HttpUrl url = HttpUrl.parse(linkUrl); - return url != null && - !TextUtils.isEmpty(url.scheme()) && - "https".equals(url.scheme()) && - isLegalUrl(linkUrl); - } - - /** - * @return True if the top-level domain is valid. - */ - public static boolean isValidMediaUrl(@Nullable String mediaUrl) { - if (mediaUrl == null) return false; - - HttpUrl url = HttpUrl.parse(mediaUrl); - return url != null && - !TextUtils.isEmpty(url.scheme()) && - "https".equals(url.scheme()) && - isLegalUrl(mediaUrl); - } - - public static boolean isLegalUrl(@NonNull String url) { - Matcher matcher = DOMAIN_PATTERN.matcher(url); - - if (matcher.matches()) { - String domain = matcher.group(2); - String cleanedDomain = domain.replaceAll("\\.", ""); - - return ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() || - ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches(); - } else { - return false; - } - } - - public static boolean isValidMimeType(@NonNull String url) { - String[] validMimeType = {".jpg", ".png", ".gif", ".jpeg"}; - if (url.contains(".")) { - for (String mimeType : validMimeType) { - if (url.contains(mimeType)) { - return true; - } - } - return false; - } - return true; - } - - public static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html) { - return parseOpenGraphFields(html, text -> Html.fromHtml(text).toString()); - } - - static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html, @NonNull HtmlDecoder htmlDecoder) { - if (html == null) { - return new OpenGraph(Collections.emptyMap(), null, null); - } - - Map openGraphTags = new HashMap<>(); - Matcher openGraphMatcher = OPEN_GRAPH_TAG_PATTERN.matcher(html); - - while (openGraphMatcher.find()) { - String tag = openGraphMatcher.group(); - String property = openGraphMatcher.groupCount() > 0 ? openGraphMatcher.group(1) : null; - - if (property != null) { - Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); - if (contentMatcher.find() && contentMatcher.groupCount() > 0) { - String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); - openGraphTags.put(property.toLowerCase(), content); - } - } - } - - Matcher articleMatcher = ARTICLE_TAG_PATTERN.matcher(html); - - while (articleMatcher.find()) { - String tag = articleMatcher.group(); - String property = articleMatcher.groupCount() > 0 ? articleMatcher.group(1) : null; - - if (property != null) { - Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); - if (contentMatcher.find() && contentMatcher.groupCount() > 0) { - String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); - openGraphTags.put(property.toLowerCase(), content); - } - } - } - - String htmlTitle = ""; - String faviconUrl = ""; - - Matcher titleMatcher = TITLE_PATTERN.matcher(html); - if (titleMatcher.find() && titleMatcher.groupCount() > 0) { - htmlTitle = htmlDecoder.fromEncoded(titleMatcher.group(1)); - } - - Matcher faviconMatcher = FAVICON_PATTERN.matcher(html); - if (faviconMatcher.find()) { - Matcher faviconHrefMatcher = FAVICON_HREF_PATTERN.matcher(faviconMatcher.group()); - if (faviconHrefMatcher.find() && faviconHrefMatcher.groupCount() > 0) { - faviconUrl = faviconHrefMatcher.group(1); - } - } - - return new OpenGraph(openGraphTags, htmlTitle, faviconUrl); - } - - public static final class OpenGraph { - - private final Map values; - - private final @Nullable String htmlTitle; - private final @Nullable String faviconUrl; - - private static final String KEY_TITLE = "title"; - private static final String KEY_DESCRIPTION_URL = "description"; - private static final String KEY_IMAGE_URL = "image"; - private static final String KEY_PUBLISHED_TIME_1 = "published_time"; - private static final String KEY_PUBLISHED_TIME_2 = "article:published_time"; - private static final String KEY_MODIFIED_TIME_1 = "modified_time"; - private static final String KEY_MODIFIED_TIME_2 = "article:modified_time"; - - public OpenGraph(@NonNull Map values, @Nullable String htmlTitle, @Nullable String faviconUrl) { - this.values = values; - this.htmlTitle = htmlTitle; - this.faviconUrl = faviconUrl; - } - - public String getTitle() { - return Util.getFirstNonEmpty(values.get(KEY_TITLE), htmlTitle); - } - - public String getImageUrl() { - return Util.getFirstNonEmpty(values.get(KEY_IMAGE_URL), faviconUrl); - } - - private static long parseISO8601(String date) { - - if (date == null || date.isEmpty()) { return -1L; } - - SimpleDateFormat format; - format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); - - try { - return format.parse(date).getTime(); - } catch (ParseException pe) { - Log.w("OpenGraph", "Failed to parse date.", pe); - return -1L; - } - } - - @SuppressLint("ObsoleteSdkInt") - public long getDate() { - String[] candidates = new String[] { - values.get(KEY_PUBLISHED_TIME_1), - values.get(KEY_PUBLISHED_TIME_2), - values.get(KEY_MODIFIED_TIME_1), - values.get(KEY_MODIFIED_TIME_2) - }; - - for (String c : candidates) { - long t = parseISO8601(c); - if (t > 0) return t; - } - - return 0L; - } - } - - public interface HtmlDecoder { - @NonNull String fromEncoded(@NonNull String html); - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt new file mode 100644 index 0000000000..36c2e445da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt @@ -0,0 +1,216 @@ +package org.thoughtcrime.securesms.linkpreview + +import android.annotation.SuppressLint +import android.text.SpannableString +import android.text.style.URLSpan +import androidx.core.text.HtmlCompat +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.session.libsession.utilities.Util +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.conversation.v2.Util.addUrlSpansWithAutolink +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +object LinkPreviewUtil { + + private val DOMAIN_PATTERN = Regex("^(https?://)?([^/]+).*$", RegexOption.IGNORE_CASE) + private val ALL_ASCII_PATTERN = Regex("^[\\x00-\\x7F]*$", RegexOption.IGNORE_CASE) + private val ALL_NON_ASCII_PATTERN = Regex("^[^\\x00-\\x7F]*$", RegexOption.IGNORE_CASE) + + private val OPEN_GRAPH_TAG_PATTERN = Regex( + "<\\s*meta[^>]*property\\s*=\\s*\"\\s*og:([^\"]+)\"[^>]*/?\\s*>", + RegexOption.IGNORE_CASE + ) + private val ARTICLE_TAG_PATTERN = Regex( + "<\\s*meta[^>]*property\\s*=\\s*\"\\s*article:([^\"]+)\"[^>]*/?\\s*>", + RegexOption.IGNORE_CASE + ) + private val OPEN_GRAPH_CONTENT_PATTERN = + Regex("content\\s*=\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE) + private val TITLE_PATTERN = + Regex("<\\s*title[^>]*>(.*)<\\s*/title[^>]*>", RegexOption.IGNORE_CASE) + private val FAVICON_PATTERN = + Regex("<\\s*link[^>]*rel\\s*=\\s*\".*icon.*\"[^>]*>", RegexOption.IGNORE_CASE) + private val FAVICON_HREF_PATTERN = Regex("href\\s*=\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE) + + /** + * @return All whitelisted URLs in the source text. + */ + @JvmStatic + fun findWhitelistedUrls(text: String): List { + val spannable = SpannableString(text) + val found = spannable.addUrlSpansWithAutolink() + + if (!found) { + return emptyList() + } + + val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java) + val links = ArrayList(spans.size) + + for (span in spans) { + val link = Link(span.url, spannable.getSpanStart(span)) + if (isValidLinkUrl(link.url)) { + links.add(link) + } + } + + return links + } + + /** + * @return True if the host is valid. + */ + @JvmStatic + fun isValidLinkUrl(linkUrl: String?): Boolean { + if (linkUrl.isNullOrEmpty()) return false + + val url = linkUrl.toHttpUrlOrNull() ?: return false + return url.scheme == "https" && isLegalUrl(linkUrl) + } + + /** + * @return True if the top-level domain is valid. + */ + @JvmStatic + fun isValidMediaUrl(mediaUrl: String?): Boolean { + if (mediaUrl.isNullOrEmpty()) return false + + val url = mediaUrl.toHttpUrlOrNull() ?: return false + return url.scheme == "https" && isLegalUrl(mediaUrl) + } + + @JvmStatic + fun isLegalUrl(url: String): Boolean { + val match = DOMAIN_PATTERN.matchEntire(url) ?: return false + val domain = match.groupValues.getOrNull(2) ?: return false + val cleanedDomain = domain.replace(".", "") + + return ALL_ASCII_PATTERN.matches(cleanedDomain) || ALL_NON_ASCII_PATTERN.matches( + cleanedDomain + ) + } + + @JvmStatic + fun isValidMimeType(url: String): Boolean { + val lower = url.lowercase(Locale.ROOT) + val validExtensions = arrayOf(".jpg", ".png", ".gif", ".jpeg") + + // If there's no dot at all, allow it. + if (!lower.contains('.')) return true + + return validExtensions.any { lower.endsWith(it) } + } + + @JvmStatic + fun parseOpenGraphFields(html: String?): OpenGraph { + return parseOpenGraphFields(html) { encoded -> + HtmlCompat.fromHtml(encoded, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() + } + } + + internal fun parseOpenGraphFields(html: String?, htmlDecoder: HtmlDecoder): OpenGraph { + if (html == null) { + return OpenGraph(emptyMap(), null, null) + } + + val openGraphTags = HashMap() + + fun extractTags(tagRegex: Regex) { + tagRegex.findAll(html).forEach { match -> + val fullTag = match.value + val property = match.groupValues.getOrNull(1) + if (!property.isNullOrEmpty()) { + val contentMatch = OPEN_GRAPH_CONTENT_PATTERN.find(fullTag) + val content = contentMatch?.groupValues?.getOrNull(1) + if (content != null) { + val decoded = htmlDecoder.fromEncoded(content) + // Store the lower-cased property name. + openGraphTags[property.lowercase()] = decoded + } + } + } + } + + extractTags(OPEN_GRAPH_TAG_PATTERN) + extractTags(ARTICLE_TAG_PATTERN) + + var htmlTitle = "" + var faviconUrl = "" + + TITLE_PATTERN.find(html)?.let { titleMatch -> + val title = titleMatch.groupValues.getOrNull(1) + if (title != null) { + htmlTitle = htmlDecoder.fromEncoded(title) + } + } + + FAVICON_PATTERN.find(html)?.let { faviconTagMatch -> + val hrefMatch = FAVICON_HREF_PATTERN.find(faviconTagMatch.value) + val href = hrefMatch?.groupValues?.getOrNull(1) + if (href != null) { + faviconUrl = href + } + } + + return OpenGraph(openGraphTags, htmlTitle, faviconUrl) + } + + class OpenGraph( + private val values: Map, + private val title: String?, + private val imageUrl: String? + ) { + + companion object { + private const val KEY_TITLE = "title" + private const val KEY_IMAGE_URL = "image" + private const val KEY_PUBLISHED_TIME_1 = "published_time" + private const val KEY_PUBLISHED_TIME_2 = "article:published_time" + private const val KEY_MODIFIED_TIME_1 = "modified_time" + private const val KEY_MODIFIED_TIME_2 = "article:modified_time" + } + + fun getTitle(): String? { + return Util.getFirstNonEmpty(values[KEY_TITLE], title) + } + + fun getImageUrl(): String? { + return Util.getFirstNonEmpty(values[KEY_IMAGE_URL], imageUrl) + } + + private fun parseISO8601(date: String?): Long { + if (date.isNullOrEmpty()) return -1L + + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + return try { + format.parse(date)?.time ?: -1L + } catch (pe: ParseException) { + Log.w("OpenGraph", "Failed to parse date.", pe) + -1L + } + } + + @SuppressLint("ObsoleteSdkInt") + fun getDate(): Long { + val candidates = arrayOf( + values[KEY_PUBLISHED_TIME_1], + values[KEY_PUBLISHED_TIME_2], + values[KEY_MODIFIED_TIME_1], + values[KEY_MODIFIED_TIME_2] + ) + + for (c in candidates) { + val t = parseISO8601(c) + if (t > 0) return t + } + + return 0L + } + } + + fun interface HtmlDecoder { + fun fromEncoded(html: String): String + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java index 983a3374ef..758ebe3409 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java @@ -3,8 +3,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import java.io.IOException; From d0a7dc863fa9045749f12882c8be71de11a84fc5 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 16 Feb 2026 11:48:31 +0800 Subject: [PATCH 2/7] Refactored LinkPreviewUtils to kotlin, used autoLink instead of linkify for previews --- .../securesms/linkpreview/LinkPreviewUtil.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt index 36c2e445da..e6d4651a70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt @@ -8,6 +8,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.utilities.Util import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.Util.addUrlSpansWithAutolink +import java.net.URI import java.text.ParseException import java.text.SimpleDateFormat import java.util.Locale @@ -40,11 +41,7 @@ object LinkPreviewUtil { @JvmStatic fun findWhitelistedUrls(text: String): List { val spannable = SpannableString(text) - val found = spannable.addUrlSpansWithAutolink() - - if (!found) { - return emptyList() - } + spannable.addUrlSpansWithAutolink() val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java) val links = ArrayList(spans.size) @@ -94,13 +91,17 @@ object LinkPreviewUtil { @JvmStatic fun isValidMimeType(url: String): Boolean { - val lower = url.lowercase(Locale.ROOT) + val path = try { + URI(url).path.lowercase(Locale.ROOT) + } catch (e: Exception) { + return false + } + val validExtensions = arrayOf(".jpg", ".png", ".gif", ".jpeg") - // If there's no dot at all, allow it. - if (!lower.contains('.')) return true + if (!path.contains('.')) return true - return validExtensions.any { lower.endsWith(it) } + return validExtensions.any { path.endsWith(it) } } @JvmStatic From c098dd48f7db47fd4b3a72d3efb636c24e241a47 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 16 Feb 2026 11:50:05 +0800 Subject: [PATCH 3/7] Cleanups --- .../main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt index 3565e5d8b7..d555ab46a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt @@ -17,7 +17,6 @@ package org.thoughtcrime.securesms.conversation.v2 import android.text.Spannable -import android.text.SpannableString import android.text.style.URLSpan import org.nibor.autolink.LinkExtractor import org.nibor.autolink.LinkType From 1f2074b61e23806630ffde9bac939c1454e268e4 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 16 Feb 2026 12:07:01 +0800 Subject: [PATCH 4/7] Cleanup, ContentProxySafetyInterceptor refactored to kotlin --- .../securesms/linkpreview/LinkPreviewUtil.kt | 6 -- .../net/ContentProxySafetyInterceptor.java | 58 ------------------- .../net/ContentProxySafetyInterceptor.kt | 55 ++++++++++++++++++ 3 files changed, 55 insertions(+), 64 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt index e6d4651a70..046b5aac76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt @@ -38,7 +38,6 @@ object LinkPreviewUtil { /** * @return All whitelisted URLs in the source text. */ - @JvmStatic fun findWhitelistedUrls(text: String): List { val spannable = SpannableString(text) spannable.addUrlSpansWithAutolink() @@ -59,7 +58,6 @@ object LinkPreviewUtil { /** * @return True if the host is valid. */ - @JvmStatic fun isValidLinkUrl(linkUrl: String?): Boolean { if (linkUrl.isNullOrEmpty()) return false @@ -70,7 +68,6 @@ object LinkPreviewUtil { /** * @return True if the top-level domain is valid. */ - @JvmStatic fun isValidMediaUrl(mediaUrl: String?): Boolean { if (mediaUrl.isNullOrEmpty()) return false @@ -78,7 +75,6 @@ object LinkPreviewUtil { return url.scheme == "https" && isLegalUrl(mediaUrl) } - @JvmStatic fun isLegalUrl(url: String): Boolean { val match = DOMAIN_PATTERN.matchEntire(url) ?: return false val domain = match.groupValues.getOrNull(2) ?: return false @@ -89,7 +85,6 @@ object LinkPreviewUtil { ) } - @JvmStatic fun isValidMimeType(url: String): Boolean { val path = try { URI(url).path.lowercase(Locale.ROOT) @@ -104,7 +99,6 @@ object LinkPreviewUtil { return validExtensions.any { path.endsWith(it) } } - @JvmStatic fun parseOpenGraphFields(html: String?): OpenGraph { return parseOpenGraphFields(html) { encoded -> HtmlCompat.fromHtml(encoded, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java deleted file mode 100644 index 758ebe3409..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.thoughtcrime.securesms.net; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; - -import java.io.IOException; - -import okhttp3.HttpUrl; -import okhttp3.Interceptor; -import okhttp3.Response; - -/** - * Interceptor to do extra safety checks on requests through the {@link ContentProxySelector} - * to prevent non-whitelisted requests from getting to it. In particular, this guards against - * requests redirecting to non-whitelisted domains. - * - * Note that because of the way interceptors are ordered, OkHttp will hit the proxy with the - * bad-redirected-domain before we can intercept the request, so we have to "look ahead" by - * detecting a redirected response on the first pass. - */ -public class ContentProxySafetyInterceptor implements Interceptor { - - private static final String TAG = Log.tag(ContentProxySafetyInterceptor.class); - - @Override - public @NonNull Response intercept(@NonNull Chain chain) throws IOException { - if (isWhitelisted(chain.request().url())) { - Response response = chain.proceed(chain.request()); - - if (response.isRedirect()) { - if (isWhitelisted(response.header("location")) || isWhitelisted(response.header("Location"))) { - return response; - } else { - Log.w(TAG, "Tried to redirect to a non-whitelisted domain!"); - chain.call().cancel(); - throw new IOException("Tried to redirect to a non-whitelisted domain!"); - } - } else { - return response; - } - } else { - Log.w(TAG, "Request was for a non-whitelisted domain!"); - chain.call().cancel(); - throw new IOException("Request was for a non-whitelisted domain!"); - } - } - - private static boolean isWhitelisted(@NonNull HttpUrl url) { - return isWhitelisted(url.toString()); - } - - private static boolean isWhitelisted(@Nullable String url) { - return LinkPreviewUtil.isValidLinkUrl(url) || LinkPreviewUtil.isValidMediaUrl(url); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.kt b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.kt new file mode 100644 index 0000000000..5857964a32 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.net + +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil.isValidLinkUrl +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil.isValidMediaUrl +import java.io.IOException + +/** + * Interceptor to do extra safety checks on requests through the [ContentProxySelector] + * to prevent non-whitelisted requests from getting to it. In particular, this guards against + * requests redirecting to non-whitelisted domains. + * + * Note that because of the way interceptors are ordered, OkHttp will hit the proxy with the + * bad-redirected-domain before we can intercept the request, so we have to "look ahead" by + * detecting a redirected response on the first pass. + */ +class ContentProxySafetyInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + if (isWhitelisted(chain.request().url)) { + val response = chain.proceed(chain.request()) + + if (response.isRedirect) { + if (isWhitelisted(response.header("location")) || isWhitelisted(response.header("Location"))) { + return response + } else { + Log.w(TAG, "Tried to redirect to a non-whitelisted domain!") + chain.call().cancel() + throw IOException("Tried to redirect to a non-whitelisted domain!") + } + } else { + return response + } + } else { + Log.w(TAG, "Request was for a non-whitelisted domain!") + chain.call().cancel() + throw IOException("Request was for a non-whitelisted domain!") + } + } + + companion object { + private val TAG: String = Log.tag(ContentProxySafetyInterceptor::class.java) + + private fun isWhitelisted(url: HttpUrl): Boolean { + return isWhitelisted(url.toString()) + } + + private fun isWhitelisted(url: String?): Boolean { + return isValidLinkUrl(url) || isValidMediaUrl(url) + } + } +} From e50e745de81a3ba44c9055463682b4c9f61cd34c Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 16 Feb 2026 12:14:17 +0800 Subject: [PATCH 5/7] Cleanups --- .../securesms/linkpreview/LinkPreviewRepository.kt | 4 ++-- .../securesms/linkpreview/LinkPreviewUtil.kt | 14 ++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.kt index c3c5daf1b3..786eaede07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.kt @@ -126,8 +126,8 @@ class LinkPreviewRepository { val body = bodyObj.string() val openGraph: OpenGraph = LinkPreviewUtil.parseOpenGraphFields(body) - val title: String? = openGraph.getTitle() - var imageUrl: String? = openGraph.getImageUrl() + val title: String? = openGraph.title + var imageUrl: String? = openGraph.imageUrl if (!imageUrl.isNullOrEmpty() && !LinkPreviewUtil.isValidMediaUrl(imageUrl)) { Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt index 046b5aac76..2cba231222 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.kt @@ -154,8 +154,8 @@ object LinkPreviewUtil { class OpenGraph( private val values: Map, - private val title: String?, - private val imageUrl: String? + private val htmlTitle: String?, + private val faviconUrl: String? ) { companion object { @@ -167,13 +167,11 @@ object LinkPreviewUtil { private const val KEY_MODIFIED_TIME_2 = "article:modified_time" } - fun getTitle(): String? { - return Util.getFirstNonEmpty(values[KEY_TITLE], title) - } + val title: String? + get() = Util.getFirstNonEmpty(values[KEY_TITLE], htmlTitle) - fun getImageUrl(): String? { - return Util.getFirstNonEmpty(values[KEY_IMAGE_URL], imageUrl) - } + val imageUrl: String? + get() = Util.getFirstNonEmpty(values[KEY_IMAGE_URL], faviconUrl) private fun parseISO8601(date: String?): Long { if (date.isNullOrEmpty()) return -1L From a42046b95cb8cdfb86897f0ecaa8ade99c9bbd79 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 16 Feb 2026 12:59:27 +0800 Subject: [PATCH 6/7] Converted test to kotlin --- .../linkpreview/LinkPreviewUtilTest.java | 73 ------------------- .../linkpreview/LinkPreviewUtilTest.kt | 67 +++++++++++++++++ 2 files changed, 67 insertions(+), 73 deletions(-) delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest.kt diff --git a/app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest.java b/app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest.java deleted file mode 100644 index e45d5b3897..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.thoughtcrime.securesms.linkpreview; - -import org.junit.Test; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static junit.framework.TestCase.assertEquals; -import static junit.framework.TestCase.assertFalse; -import static junit.framework.TestCase.assertTrue; - -public class LinkPreviewUtilTest { - - @Test - public void isLegal_allAscii_noProtocol() { - assertTrue(LinkPreviewUtil.isLegalUrl("google.com")); - } - - @Test - public void isLegal_allAscii_noProtocol_subdomain() { - assertTrue(LinkPreviewUtil.isLegalUrl("foo.google.com")); - } - - @Test - public void isLegal_allAscii_subdomain() { - assertTrue(LinkPreviewUtil.isLegalUrl("https://foo.google.com")); - } - - @Test - public void isLegal_allAscii_subdomain_path() { - assertTrue(LinkPreviewUtil.isLegalUrl("https://foo.google.com/some/path.html")); - } - - @Test - public void isLegal_cyrillicHostAsciiTld() { - assertFalse(LinkPreviewUtil.isLegalUrl("http://кц.com")); - } - - @Test - public void isLegal_cyrillicHostAsciiTld_noProtocol() { - assertFalse(LinkPreviewUtil.isLegalUrl("кц.com")); - } - - @Test - public void isLegal_mixedHost_noProtocol() { - assertFalse(LinkPreviewUtil.isLegalUrl("http://asĸ.com")); - } - - @Test - public void isLegal_cyrillicHostAndTld_noProtocol() { - assertTrue(LinkPreviewUtil.isLegalUrl("кц.рф")); - } - - @Test - public void isLegal_cyrillicHostAndTld_asciiPath_noProtocol() { - assertTrue(LinkPreviewUtil.isLegalUrl("кц.рф/some/path")); - } - - @Test - public void isLegal_cyrillicHostAndTld_asciiPath() { - assertTrue(LinkPreviewUtil.isLegalUrl("https://кц.рф/some/path")); - } - - @Test - public void isLegal_asciiSubdomain_cyrillicHostAndTld() { - assertFalse(LinkPreviewUtil.isLegalUrl("http://foo.кц.рф")); - } - - @Test - public void isLegal_emptyUrl() { - assertFalse(LinkPreviewUtil.isLegalUrl("")); - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest.kt new file mode 100644 index 0000000000..965b57b68f --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest.kt @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.linkpreview + +import junit.framework.TestCase +import org.junit.Test +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil.isLegalUrl + +class LinkPreviewUtilTest { + @Test + fun isLegal_allAscii_noProtocol() { + TestCase.assertTrue(isLegalUrl("google.com")) + } + + @Test + fun isLegal_allAscii_noProtocol_subdomain() { + TestCase.assertTrue(isLegalUrl("foo.google.com")) + } + + @Test + fun isLegal_allAscii_subdomain() { + TestCase.assertTrue(isLegalUrl("https://foo.google.com")) + } + + @Test + fun isLegal_allAscii_subdomain_path() { + TestCase.assertTrue(isLegalUrl("https://foo.google.com/some/path.html")) + } + + @Test + fun isLegal_cyrillicHostAsciiTld() { + TestCase.assertFalse(isLegalUrl("http://кц.com")) + } + + @Test + fun isLegal_cyrillicHostAsciiTld_noProtocol() { + TestCase.assertFalse(isLegalUrl("кц.com")) + } + + @Test + fun isLegal_mixedHost_noProtocol() { + TestCase.assertFalse(isLegalUrl("http://asĸ.com")) + } + + @Test + fun isLegal_cyrillicHostAndTld_noProtocol() { + TestCase.assertTrue(isLegalUrl("кц.рф")) + } + + @Test + fun isLegal_cyrillicHostAndTld_asciiPath_noProtocol() { + TestCase.assertTrue(isLegalUrl("кц.рф/some/path")) + } + + @Test + fun isLegal_cyrillicHostAndTld_asciiPath() { + TestCase.assertTrue(isLegalUrl("https://кц.рф/some/path")) + } + + @Test + fun isLegal_asciiSubdomain_cyrillicHostAndTld() { + TestCase.assertFalse(isLegalUrl("http://foo.кц.рф")) + } + + @Test + fun isLegal_emptyUrl() { + TestCase.assertFalse(isLegalUrl("")) + } +} From 9fd81138ba5d9152ab6ca327867ae6d1a92e040d Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 16 Feb 2026 13:07:49 +0800 Subject: [PATCH 7/7] Cleanups --- .../java/org/thoughtcrime/securesms/conversation/v2/Util.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt index d555ab46a8..cdde239e0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt @@ -42,10 +42,8 @@ object Util { /** * Uses autolink-java to detect URLs with better boundaries than Android Linkify, * and applies standard URLSpan spans only. - * - * Also returns TRUE if any links were created */ - fun Spannable.addUrlSpansWithAutolink() : Boolean { + fun Spannable.addUrlSpansWithAutolink() { // Remove any existing URLSpans first so we don't get overlapping links getSpans(0, length, URLSpan::class.java).forEach { removeSpan(it) } @@ -67,7 +65,5 @@ object Util { setSpan(URLSpan(url), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } - - return links.any() } } \ No newline at end of file