Add support for article dates in link previews.

This commit is contained in:
Greyson Parrelli 2020-08-26 16:03:52 -04:00 committed by Alan Evans
parent bfed03b7b5
commit dd8b9ff8fb
13 changed files with 124 additions and 23 deletions

View File

@ -23,6 +23,10 @@ import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Locale;
import okhttp3.HttpUrl; import okhttp3.HttpUrl;
/** /**
@ -146,14 +150,24 @@ public class LinkPreviewView extends FrameLayout {
description.setVisibility(GONE); description.setVisibility(GONE);
} }
String domain = null;
if (!Util.isEmpty(linkPreview.getUrl())) { if (!Util.isEmpty(linkPreview.getUrl())) {
HttpUrl url = HttpUrl.parse(linkPreview.getUrl()); HttpUrl url = HttpUrl.parse(linkPreview.getUrl());
if (url != null) { if (url != null) {
site.setText(url.topPrivateDomain()); domain = url.topPrivateDomain();
site.setVisibility(VISIBLE);
} else {
site.setVisibility(GONE);
} }
}
if (domain != null && linkPreview.getDate() > 0) {
site.setText(getContext().getString(R.string.LinkPreviewView_domain_date, domain, formatDate(linkPreview.getDate())));
site.setVisibility(VISIBLE);
} else if (domain != null) {
site.setText(domain);
site.setVisibility(VISIBLE);
} else if (linkPreview.getDate() > 0) {
site.setText(formatDate(linkPreview.getDate()));
site.setVisibility(VISIBLE);
} else { } else {
site.setVisibility(GONE); site.setVisibility(GONE);
} }
@ -187,6 +201,11 @@ public class LinkPreviewView extends FrameLayout {
: R.string.LinkPreviewView_no_link_preview_available; : R.string.LinkPreviewView_no_link_preview_available;
} }
private static String formatDate(long date) {
DateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy", Locale.getDefault());
return dateFormat.format(date);
}
public interface CloseClickedListener { public interface CloseClickedListener {
void onCloseClicked(); void onCloseClicked();
} }

View File

@ -1118,7 +1118,7 @@ public class MmsDatabase extends MessageDatabase {
if (preview.getAttachmentId() != null) { if (preview.getAttachmentId() != null) {
DatabaseAttachment attachment = attachmentIdMap.get(preview.getAttachmentId()); DatabaseAttachment attachment = attachmentIdMap.get(preview.getAttachmentId());
if (attachment != null) { if (attachment != null) {
previews.add(new LinkPreview(preview.getUrl(), preview.getTitle(), preview.getDescription(), attachment)); previews.add(new LinkPreview(preview.getUrl(), preview.getTitle(), preview.getDescription(), preview.getDate(), attachment));
} }
} else { } else {
previews.add(preview); previews.add(preview);
@ -1526,7 +1526,7 @@ public class MmsDatabase extends MessageDatabase {
attachmentId = insertedAttachmentIds.get(preview.getThumbnail().get()); attachmentId = insertedAttachmentIds.get(preview.getThumbnail().get());
} }
LinkPreview updatedPreview = new LinkPreview(preview.getUrl(), preview.getTitle(), preview.getDescription(), attachmentId); LinkPreview updatedPreview = new LinkPreview(preview.getUrl(), preview.getTitle(), preview.getDescription(), preview.getDate(), attachmentId);
linkPreviewJson.put(new JSONObject(updatedPreview.serialize())); linkPreviewJson.put(new JSONObject(updatedPreview.serialize()));
} catch (JSONException | IOException e) { } catch (JSONException | IOException e) {
Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e); Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e);

View File

@ -1696,7 +1696,7 @@ public final class PushProcessMessageJob extends BaseJob {
boolean validDomain = url.isPresent() && LinkPreviewUtil.isValidPreviewUrl(url.get()); boolean validDomain = url.isPresent() && LinkPreviewUtil.isValidPreviewUrl(url.get());
if (hasTitle && presentInBody && validDomain) { if (hasTitle && presentInBody && validDomain) {
LinkPreview linkPreview = new LinkPreview(url.get(), title.or(""), description.or(""), thumbnail); LinkPreview linkPreview = new LinkPreview(url.get(), title.or(""), description.or(""), preview.getDate(), thumbnail);
linkPreviews.add(linkPreview); linkPreviews.add(linkPreview);
} else { } else {
Log.w(TAG, String.format("Discarding an invalid link preview. hasTitle: %b presentInBody: %b validDomain: %b", hasTitle, presentInBody, validDomain)); Log.w(TAG, String.format("Discarding an invalid link preview. hasTitle: %b presentInBody: %b validDomain: %b", hasTitle, presentInBody, validDomain));

View File

@ -315,7 +315,7 @@ public abstract class PushSendJob extends SendJob {
List<Preview> getPreviewsFor(OutgoingMediaMessage mediaMessage) { List<Preview> getPreviewsFor(OutgoingMediaMessage mediaMessage) {
return Stream.of(mediaMessage.getLinkPreviews()).map(lp -> { return Stream.of(mediaMessage.getLinkPreviews()).map(lp -> {
SignalServiceAttachment attachment = lp.getThumbnail().isPresent() ? getAttachmentPointerFor(lp.getThumbnail().get()) : null; SignalServiceAttachment attachment = lp.getThumbnail().isPresent() ? getAttachmentPointerFor(lp.getThumbnail().get()) : null;
return new Preview(lp.getUrl(), lp.getTitle(), lp.getDescription(), Optional.fromNullable(attachment)); return new Preview(lp.getUrl(), lp.getTitle(), lp.getDescription(), lp.getDate(), Optional.fromNullable(attachment));
}).toList(); }).toList();
} }

View File

@ -25,24 +25,29 @@ public class LinkPreview {
@JsonProperty @JsonProperty
private final String description; private final String description;
@JsonProperty
private final long date;
@JsonProperty @JsonProperty
private final AttachmentId attachmentId; private final AttachmentId attachmentId;
@JsonIgnore @JsonIgnore
private final Optional<Attachment> thumbnail; private final Optional<Attachment> thumbnail;
public LinkPreview(@NonNull String url, @NonNull String title, @NonNull String description, @NonNull DatabaseAttachment thumbnail) { public LinkPreview(@NonNull String url, @NonNull String title, @NonNull String description, long date, @NonNull DatabaseAttachment thumbnail) {
this.url = url; this.url = url;
this.title = title; this.title = title;
this.description = description; this.description = description;
this.date = date;
this.thumbnail = Optional.of(thumbnail); this.thumbnail = Optional.of(thumbnail);
this.attachmentId = thumbnail.getAttachmentId(); this.attachmentId = thumbnail.getAttachmentId();
} }
public LinkPreview(@NonNull String url, @NonNull String title, @NonNull String description, @NonNull Optional<Attachment> thumbnail) { public LinkPreview(@NonNull String url, @NonNull String title, @NonNull String description, long date, @NonNull Optional<Attachment> thumbnail) {
this.url = url; this.url = url;
this.title = title; this.title = title;
this.description = description; this.description = description;
this.date = date;
this.thumbnail = thumbnail; this.thumbnail = thumbnail;
this.attachmentId = null; this.attachmentId = null;
} }
@ -50,11 +55,13 @@ public class LinkPreview {
public LinkPreview(@JsonProperty("url") @NonNull String url, public LinkPreview(@JsonProperty("url") @NonNull String url,
@JsonProperty("title") @NonNull String title, @JsonProperty("title") @NonNull String title,
@JsonProperty("description") @Nullable String description, @JsonProperty("description") @Nullable String description,
@JsonProperty("date") long date,
@JsonProperty("attachmentId") @Nullable AttachmentId attachmentId) @JsonProperty("attachmentId") @Nullable AttachmentId attachmentId)
{ {
this.url = url; this.url = url;
this.title = title; this.title = title;
this.description = Optional.fromNullable(description).or(""); this.description = Optional.fromNullable(description).or("");
this.date = date;
this.attachmentId = attachmentId; this.attachmentId = attachmentId;
this.thumbnail = Optional.absent(); this.thumbnail = Optional.absent();
} }
@ -71,6 +78,10 @@ public class LinkPreview {
return description; return description;
} }
public long getDate() {
return date;
}
public @NonNull Optional<Attachment> getThumbnail() { public @NonNull Optional<Attachment> getThumbnail() {
return thumbnail; return thumbnail;
} }

View File

@ -106,7 +106,7 @@ public class LinkPreviewRepository {
} }
if (!metadata.getImageUrl().isPresent()) { if (!metadata.getImageUrl().isPresent()) {
callback.onSuccess(new LinkPreview(url, metadata.getTitle().or(""), metadata.getDescription().or(""), Optional.absent())); callback.onSuccess(new LinkPreview(url, metadata.getTitle().or(""), metadata.getDescription().or(""), metadata.getDate(), Optional.absent()));
return; return;
} }
@ -114,7 +114,7 @@ public class LinkPreviewRepository {
if (!metadata.getTitle().isPresent() && !attachment.isPresent()) { if (!metadata.getTitle().isPresent() && !attachment.isPresent()) {
callback.onError(Error.PREVIEW_NOT_AVAILABLE); callback.onError(Error.PREVIEW_NOT_AVAILABLE);
} else { } else {
callback.onSuccess(new LinkPreview(url, metadata.getTitle().or(""), metadata.getDescription().or(""), attachment)); callback.onSuccess(new LinkPreview(url, metadata.getTitle().or(""), metadata.getDescription().or(""), metadata.getDate(), attachment));
} }
}); });
@ -153,13 +153,14 @@ public class LinkPreviewRepository {
Optional<String> title = openGraph.getTitle(); Optional<String> title = openGraph.getTitle();
Optional<String> description = openGraph.getDescription(); Optional<String> description = openGraph.getDescription();
Optional<String> imageUrl = openGraph.getImageUrl(); Optional<String> imageUrl = openGraph.getImageUrl();
long date = openGraph.getDate();
if (imageUrl.isPresent() && !LinkPreviewUtil.isValidPreviewUrl(imageUrl.get())) { if (imageUrl.isPresent() && !LinkPreviewUtil.isValidPreviewUrl(imageUrl.get())) {
Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping."); Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping.");
imageUrl = Optional.absent(); imageUrl = Optional.absent();
} }
callback.accept(new Metadata(title, description, imageUrl)); callback.accept(new Metadata(title, description, date, imageUrl));
} }
}); });
@ -227,7 +228,7 @@ public class LinkPreviewRepository {
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP); Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
callback.onSuccess(new LinkPreview(packUrl, title, "", thumbnail)); callback.onSuccess(new LinkPreview(packUrl, title, "", 0, thumbnail));
} else { } else {
callback.onError(Error.PREVIEW_NOT_AVAILABLE); callback.onError(Error.PREVIEW_NOT_AVAILABLE);
} }
@ -272,7 +273,7 @@ public class LinkPreviewRepository {
thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP); thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
} }
callback.onSuccess(new LinkPreview(groupUrl, title, description, thumbnail)); callback.onSuccess(new LinkPreview(groupUrl, title, description, 0, thumbnail));
} else { } else {
Log.i(TAG, "Group is not locally available for preview generation, fetching from server"); Log.i(TAG, "Group is not locally available for preview generation, fetching from server");
@ -289,7 +290,7 @@ public class LinkPreviewRepository {
if (bitmap != null) bitmap.recycle(); if (bitmap != null) bitmap.recycle();
} }
callback.onSuccess(new LinkPreview(groupUrl, joinInfo.getTitle(), description, thumbnail)); callback.onSuccess(new LinkPreview(groupUrl, joinInfo.getTitle(), description, 0, thumbnail));
} }
} catch (ExecutionException | InterruptedException | IOException | VerificationFailedException e) { } catch (ExecutionException | InterruptedException | IOException | VerificationFailedException e) {
Log.w(TAG, "Failed to fetch group link preview.", e); Log.w(TAG, "Failed to fetch group link preview.", e);
@ -350,16 +351,18 @@ public class LinkPreviewRepository {
private static class Metadata { private static class Metadata {
private final Optional<String> title; private final Optional<String> title;
private final Optional<String> description; private final Optional<String> description;
private final long date;
private final Optional<String> imageUrl; private final Optional<String> imageUrl;
Metadata(Optional<String> title, Optional<String> description, Optional<String> imageUrl) { Metadata(Optional<String> title, Optional<String> description, long date, Optional<String> imageUrl) {
this.title = title; this.title = title;
this.description = description; this.description = description;
this.date = date;
this.imageUrl = imageUrl; this.imageUrl = imageUrl;
} }
static Metadata empty() { static Metadata empty() {
return new Metadata(Optional.absent(), Optional.absent(), Optional.absent()); return new Metadata(Optional.absent(), Optional.absent(), 0, Optional.absent());
} }
Optional<String> getTitle() { Optional<String> getTitle() {
@ -370,6 +373,10 @@ public class LinkPreviewRepository {
return description; return description;
} }
long getDate() {
return date;
}
Optional<String> getImageUrl() { Optional<String> getImageUrl() {
return imageUrl; return imageUrl;
} }

View File

@ -13,14 +13,19 @@ import android.text.util.Linkify;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import com.google.android.collect.Sets; import com.google.android.collect.Sets;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.stickers.StickerUrl; import org.thoughtcrime.securesms.stickers.StickerUrl;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.util.OptionalUtil; import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@ -30,10 +35,13 @@ import okhttp3.HttpUrl;
public final class LinkPreviewUtil { public final class LinkPreviewUtil {
private static final String TAG = Log.tag(LinkPreviewUtil.class);
private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$"); 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_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$");
private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$"); private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$");
private static final Pattern OPEN_GRAPH_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*og:([^\"]+)\"[^>]*/?\\s*>"); private static final Pattern OPEN_GRAPH_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*og:([^\"]+)\"[^>]*/?\\s*>");
private static final Pattern ARTICLE_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*article:([^\"]+)\"[^>]*/?\\s*>");
private static final Pattern OPEN_GRAPH_CONTENT_PATTERN = Pattern.compile("content\\s*=\\s*\"([^\"]*)\""); private static final Pattern OPEN_GRAPH_CONTENT_PATTERN = Pattern.compile("content\\s*=\\s*\"([^\"]*)\"");
private static final Pattern TITLE_PATTERN = Pattern.compile("<\\s*title[^>]*>(.*)<\\s*/title[^>]*>"); private static final Pattern TITLE_PATTERN = Pattern.compile("<\\s*title[^>]*>(.*)<\\s*/title[^>]*>");
private static final Pattern FAVICON_PATTERN = Pattern.compile("<\\s*link[^>]*rel\\s*=\\s*\".*icon.*\"[^>]*>"); private static final Pattern FAVICON_PATTERN = Pattern.compile("<\\s*link[^>]*rel\\s*=\\s*\".*icon.*\"[^>]*>");
@ -112,7 +120,22 @@ public final class LinkPreviewUtil {
Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag);
if (contentMatcher.find() && contentMatcher.groupCount() > 0) { if (contentMatcher.find() && contentMatcher.groupCount() > 0) {
String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); String content = htmlDecoder.fromEncoded(contentMatcher.group(1));
openGraphTags.put(property, content); 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);
} }
} }
} }
@ -154,9 +177,13 @@ public final class LinkPreviewUtil {
private final @Nullable String htmlTitle; private final @Nullable String htmlTitle;
private final @Nullable String faviconUrl; private final @Nullable String faviconUrl;
private static final String KEY_TITLE = "title"; private static final String KEY_TITLE = "title";
private static final String KEY_DESCRIPTION_URL = "description"; private static final String KEY_DESCRIPTION_URL = "description";
private static final String KEY_IMAGE_URL = "image"; 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<String, String> values, @Nullable String htmlTitle, @Nullable String faviconUrl) { public OpenGraph(@NonNull Map<String, String> values, @Nullable String htmlTitle, @Nullable String faviconUrl) {
this.values = values; this.values = values;
@ -172,9 +199,35 @@ public final class LinkPreviewUtil {
return OptionalUtil.absentIfEmpty(Util.getFirstNonEmpty(values.get(KEY_IMAGE_URL), faviconUrl)); return OptionalUtil.absentIfEmpty(Util.getFirstNonEmpty(values.get(KEY_IMAGE_URL), faviconUrl));
} }
public long getDate() {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault());
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(dateString -> parseDate(format, dateString))
.filter(time -> time > 0)
.findFirst()
.orElse(0L);
}
public @NonNull Optional<String> getDescription() { public @NonNull Optional<String> getDescription() {
return OptionalUtil.absentIfEmpty(values.get(KEY_DESCRIPTION_URL)); return OptionalUtil.absentIfEmpty(values.get(KEY_DESCRIPTION_URL));
} }
private static long parseDate(DateFormat dateFormat, String dateString) {
if (Util.isEmpty(dateString)) {
return 0;
}
try {
return dateFormat.parse(dateString).getTime();
} catch (ParseException e) {
Log.w(TAG, "Failed to parse date.", e);
return 0;
}
}
} }
public interface HtmlDecoder { public interface HtmlDecoder {

View File

@ -66,6 +66,7 @@
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
android:textColor="?linkpreview_secondary_text_color" android:textColor="?linkpreview_secondary_text_color"
android:maxLines="2"
app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail" app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail"
app:layout_constraintTop_toBottomOf="@+id/linkpreview_description" app:layout_constraintTop_toBottomOf="@+id/linkpreview_description"
tools:text="dailybugle.com" /> tools:text="dailybugle.com" />

View File

@ -497,6 +497,7 @@
<!-- LinkPreviewView --> <!-- LinkPreviewView -->
<string name="LinkPreviewView_no_link_preview_available">No link preview available</string> <string name="LinkPreviewView_no_link_preview_available">No link preview available</string>
<string name="LinkPreviewView_this_group_link_is_not_active">This group link is not active</string> <string name="LinkPreviewView_this_group_link_is_not_active">This group link is not active</string>
<string name="LinkPreviewView_domain_date">%1$s · %2$s</string>
<!-- LinkPreviewRepository --> <!-- LinkPreviewRepository -->
<plurals name="LinkPreviewRepository_d_members"> <plurals name="LinkPreviewRepository_d_members">

View File

@ -629,6 +629,7 @@ public class SignalServiceMessageSender {
DataMessage.Preview.Builder previewBuilder = DataMessage.Preview.newBuilder(); DataMessage.Preview.Builder previewBuilder = DataMessage.Preview.newBuilder();
previewBuilder.setTitle(preview.getTitle()); previewBuilder.setTitle(preview.getTitle());
previewBuilder.setDescription(preview.getDescription()); previewBuilder.setDescription(preview.getDescription());
previewBuilder.setDate(preview.getDate());
previewBuilder.setUrl(preview.getUrl()); previewBuilder.setUrl(preview.getUrl());
if (preview.getImage().isPresent()) { if (preview.getImage().isPresent()) {

View File

@ -687,6 +687,7 @@ public final class SignalServiceContent {
results.add(new SignalServiceDataMessage.Preview(preview.getUrl(), results.add(new SignalServiceDataMessage.Preview(preview.getUrl(),
preview.getTitle(), preview.getTitle(),
preview.getDescription(), preview.getDescription(),
preview.getDate(),
Optional.fromNullable(attachment))); Optional.fromNullable(attachment)));
} }

View File

@ -413,12 +413,14 @@ public class SignalServiceDataMessage {
private final String url; private final String url;
private final String title; private final String title;
private final String description; private final String description;
private final long date;
private final Optional<SignalServiceAttachment> image; private final Optional<SignalServiceAttachment> image;
public Preview(String url, String title, String description, Optional<SignalServiceAttachment> image) { public Preview(String url, String title, String description, long date, Optional<SignalServiceAttachment> image) {
this.url = url; this.url = url;
this.title = title; this.title = title;
this.description = description; this.description = description;
this.date = date;
this.image = image; this.image = image;
} }
@ -434,6 +436,10 @@ public class SignalServiceDataMessage {
return description; return description;
} }
public long getDate() {
return date;
}
public Optional<SignalServiceAttachment> getImage() { public Optional<SignalServiceAttachment> getImage() {
return image; return image;
} }

View File

@ -207,6 +207,7 @@ message DataMessage {
optional string title = 2; optional string title = 2;
optional AttachmentPointer image = 3; optional AttachmentPointer image = 3;
optional string description = 4; optional string description = 4;
optional uint64 date = 5;
} }
message Sticker { message Sticker {