diff --git a/res/layout/link_preview.xml b/res/layout/link_preview.xml index 09d371485a..c4215b810a 100644 --- a/res/layout/link_preview.xml +++ b/res/layout/link_preview.xml @@ -79,10 +79,10 @@ android:layout_marginEnd="6dp" android:layout_marginTop="4dp" android:src="@drawable/ic_close_white_18dp" - android:tint="@color/gray70" android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" + app:tint="@color/gray70" tools:visibility="visible" /> callback) { - Call call = client.newCall(new Request.Builder().url(url).cacheControl(NO_CACHE).build()); + Call call = client.newCall(new Request.Builder().url(url).removeHeader("User-Agent").addHeader("User-Agent", + "WhatsApp").cacheControl(NO_CACHE).build()); call.enqueue(new okhttp3.Callback() { @Override @@ -138,14 +138,20 @@ public class LinkPreviewRepository implements InjectableType { } String body = response.body().string(); - Optional title = getProperty(body, "title"); - Optional imageUrl = getProperty(body, "image"); + OpenGraph openGraph = LinkPreviewUtil.parseOpenGraphFields(body); + Optional title = openGraph.getTitle(); + Optional imageUrl = openGraph.getImageUrl(); if (imageUrl.isPresent() && !LinkPreviewUtil.isValidMediaUrl(imageUrl.get())) { Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping."); imageUrl = Optional.absent(); } + if (imageUrl.isPresent() && !LinkPreviewUtil.isVaildMimeType(imageUrl.get())) { + Log.i(TAG, "Image URL was invalid mime type. Skipping."); + imageUrl = Optional.absent(); + } + callback.onComplete(new Metadata(title, imageUrl)); } }); @@ -161,6 +167,8 @@ public class LinkPreviewRepository implements InjectableType { try { Response response = call.execute(); if (!response.isSuccessful() || response.body() == null) { + controller.cancel(); + callback.onComplete(Optional.absent()); return; } @@ -184,24 +192,6 @@ public class LinkPreviewRepository implements InjectableType { return controller; } - private @NonNull Optional getProperty(@NonNull String searchText, @NonNull String property) { - Pattern pattern = Pattern.compile("<\\s*meta\\s+property\\s*=\\s*\"\\s*og:" + property + "\\s*\"\\s+[^>]*content\\s*=\\s*\"(.*?)\"[^>]*/?\\s*>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); - Matcher matcher = pattern.matcher(searchText); - if (matcher.find()) { - String text = Html.fromHtml(matcher.group(1)).toString(); - if (!TextUtils.isEmpty(text)) { return Optional.of(text); } - } - - pattern = Pattern.compile("<\\s*" + property + "[^>]*>(.*?)<\\s*/" + property + "[^>]*>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); - matcher = pattern.matcher(searchText); - if (matcher.find()) { - String text = Html.fromHtml(matcher.group(1)).toString(); - if (!TextUtils.isEmpty(text)) { return Optional.of(text); } - } - - return Optional.absent(); - } - private RequestController fetchStickerPackLinkPreview(@NonNull Context context, @NonNull String packUrl, @NonNull Callback> callback) diff --git a/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java index c9ca8e3b6c..be7a586e7a 100644 --- a/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java +++ b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java @@ -2,6 +2,9 @@ package org.thoughtcrime.securesms.linkpreview; import androidx.annotation.NonNull; import androidx.annotation.Nullable; + +import android.annotation.SuppressLint; +import android.text.Html; import android.text.SpannableString; import android.text.TextUtils; import android.text.style.URLSpan; @@ -10,9 +13,15 @@ import android.text.util.Linkify; import com.annimon.stream.Stream; import org.thoughtcrime.securesms.stickers.StickerUrl; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -20,10 +29,15 @@ import okhttp3.HttpUrl; public final class LinkPreviewUtil { - private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$"); - private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$"); - private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$"); - private static final Pattern STICKER_URL_PATTERN = Pattern.compile("^.*#pack_id=(.*)&pack_key=(.*)$"); + 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. @@ -82,4 +96,135 @@ public final class LinkPreviewUtil { return false; } } + + public static boolean isVaildMimeType(@NonNull String url) { + String[] vaildMimeType = {"jpg", "png", "gif", "jpeg"}; + if (url.contains(".")) { + String extenstion = url.substring(url.lastIndexOf(".") + 1).toLowerCase(); + return Arrays.asList(vaildMimeType).contains(extenstion); + } + 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); + } + + private static @Nullable String parseTopLevelDomain(@NonNull String domain) { + int periodIndex = domain.lastIndexOf("."); + + if (periodIndex >= 0 && periodIndex < domain.length() - 1) { + return domain.substring(periodIndex + 1); + } else { + return null; + } + } + + + 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 @NonNull Optional getTitle() { + return Optional.of(Util.getFirstNonEmpty(values.get(KEY_TITLE), htmlTitle)); + } + + public @NonNull Optional getImageUrl() { + return Optional.of(Util.getFirstNonEmpty(values.get(KEY_IMAGE_URL), faviconUrl)); + } + + @SuppressLint("ObsoleteSdkInt") + public long getDate() { + return Stream.of(values.get(KEY_PUBLISHED_TIME_1), + values.get(KEY_PUBLISHED_TIME_2), + values.get(KEY_MODIFIED_TIME_1), + values.get(KEY_MODIFIED_TIME_2)) + .map(DateUtils::parseIso8601) + .filter(time -> time > 0) + .findFirst() + .orElse(0L); + } + + public @NonNull + Optional getDescription() { + return Optional.of(values.get(KEY_DESCRIPTION_URL)); + } + } + + public interface HtmlDecoder { + @NonNull String fromEncoded(@NonNull String html); + } + } diff --git a/src/org/thoughtcrime/securesms/util/DateUtils.java b/src/org/thoughtcrime/securesms/util/DateUtils.java index a7fc61fb4f..901aba7dc2 100644 --- a/src/org/thoughtcrime/securesms/util/DateUtils.java +++ b/src/org/thoughtcrime/securesms/util/DateUtils.java @@ -16,10 +16,17 @@ */ package org.thoughtcrime.securesms.util; +import android.annotation.SuppressLint; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import android.os.Build; import android.text.format.DateFormat; +import org.thoughtcrime.securesms.logging.Log; + +import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; @@ -142,4 +149,33 @@ public class DateUtils extends android.text.format.DateUtils { private static String getLocalizedPattern(String template, Locale locale) { return DateFormat.getBestDateTimePattern(locale, template); } + + /** + * e.g. 2020-09-04T19:17:51Z + * https://www.iso.org/iso-8601-date-and-time-format.html + * + * Note: SDK_INT == 0 check needed to pass unit tests due to JVM date parser differences. + * + * @return The timestamp if able to be parsed, otherwise -1. + */ + @SuppressLint("ObsoleteSdkInt") + public static long parseIso8601(@Nullable String date) { + SimpleDateFormat format; + if (Build.VERSION.SDK_INT == 0 || Build.VERSION.SDK_INT >= 24) { + format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); + } else { + format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); + } + + if (date.isEmpty()) { + return -1; + } + + try { + return format.parse(date).getTime(); + } catch (ParseException e) { + Log.w(TAG, "Failed to parse date.", e); + return -1; + } + } }