diff --git a/build.gradle b/build.gradle
index 7b28540a8a..bab62557eb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -73,6 +73,7 @@ dependencies {
compile "com.android.support:preference-v14:$supportVersion"
compile "com.android.support:gridlayout-v7:$supportVersion"
compile "com.android.support:exifinterface:$supportVersion"
+ compile 'com.android.support.constraint:constraint-layout:1.1.3'
compile 'com.android.support:multidex:1.0.3'
compile 'android.arch.lifecycle:extensions:1.1.1'
compile 'android.arch.lifecycle:common-java8:1.1.1'
@@ -86,7 +87,7 @@ dependencies {
compile 'com.google.android.exoplayer:exoplayer-core:2.9.1'
compile 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
- compile 'org.whispersystems:signal-service-android:2.12.5'
+ compile 'org.whispersystems:signal-service-android:2.12.7'
compile 'org.whispersystems:webrtc-android:M69'
compile "me.leolin:ShortcutBadger:1.1.16"
@@ -172,6 +173,7 @@ dependencyVerification {
'com.android.support:cardview-v7:bc9e6b0e06ce1205f1db34f0e6193019613d19cfeb54cdccea722340d1c60f26',
'com.android.support:gridlayout-v7:5029529f7db66f8773426bf7318645f0840fc50d74f66355cd60c5e58d2da087',
'com.android.support:exifinterface:bbf44e519edd6333a24a3285aa21fd00181b920b81ca8aa89a8899f03ab4d6b0',
+ 'com.android.support.constraint:constraint-layout:27b4e5c0b80d3ff8b92f4c93b3b4d3ecf16c01589f4cdf70ca7cf64cb42d8122',
'com.android.support:multidex:ecf6098572e23b5155bab3b9a82b2fd1530eda6c6c157745e0f5287c66eec60c',
'android.arch.work:work-runtime:810fba0ee8fc58560664b58c6dba532eae05e3d196e9ee5ae78c1f22bdb292bb',
'android.arch.lifecycle:extensions:429426b2feec2245ffc5e75b3b5309bedb36159cf06dc71843ae43526ac289b6',
@@ -182,7 +184,7 @@ dependencyVerification {
'com.google.android.gms:play-services-auth:aec9e1c584d442cb9f59481a50b2c66dc191872607c04d97ecb82dd0eb5149ec',
'com.google.android.exoplayer:exoplayer-ui:7a942afcc402ff01e9bf48e8d3942850986710f06562d50a1408aaf04a683151',
'com.google.android.exoplayer:exoplayer-core:b6ab34abac36bc2bc6934b7a50008162feca2c0fde91aaf1e8c1c22f2c16e2c0',
- 'org.whispersystems:signal-service-android:d48244f9e19a4300b0baf65c2cef8c76082d55f11d331b00d098c686729cde2e',
+ 'org.whispersystems:signal-service-android:0afd2cb17ed920611dacc54385f3ed375847c10ecd7839a025d9c61c387f7678',
'org.whispersystems:webrtc-android:5493c92141ce884fc5ce8240d783232f4fe14bd17a8d0d7d1bd4944d0bd1682f',
'me.leolin:ShortcutBadger:e3cb3e7625892129b0c92dd5e4bc649faffdd526d5af26d9c45ee31ff8851774',
'se.emilsjolander:stickylistheaders:a08ca948aa6b220f09d82f16bbbac395f6b78897e9eeac6a9f0b0ba755928eeb',
@@ -251,9 +253,10 @@ dependencyVerification {
'android.arch.persistence:db-framework:bd665448330acb90a6f551a87b0ba69169da2b8ec168b92f387997339cc14311',
'android.arch.persistence:db:504e8c4307bfd53084924776ba3d49fed11b6f76d82dd80d5121c2d907fdfef6',
'com.android.support:support-annotations:5d5b9414f02d3fa0ee7526b8d5ddae0da67c8ecc8c4d63ffa6cf91488a93b927',
+ 'com.android.support.constraint:constraint-layout-solver:2cafbe356f71c208013d021f32943904798cd6459e5107f9fe27000eb5bc2aef',
'com.google.guava:listenablefuture:e4ad7607e5c0477c6f890ef26a49cb8d1bb4dffb650bab4502afee64644e3069',
'org.signal:signal-metadata-android:d9d798aab7ee7200373ecff8718baf8aaeb632c123604e8a41b7b4c0c97eeee1',
- 'org.whispersystems:signal-service-java:746b0334a2c11e978b50f6474bd67ba1aa7bc76fa96b0f3658411436238d1c79',
+ 'org.whispersystems:signal-service-java:9573395fe0b514cff10b8166f44de00a98682e0822a2b8204e9b9e696d53cb90',
'com.github.bumptech.glide:disklrucache:c1b1b6f5bbd01e2fcdc9d7f60913c8d338bdb65ed4a93bfa02b56f19daaade4b',
'com.github.bumptech.glide:annotations:bede99ef9f71517a4274bac18fd3e483e9f2b6108d7d6fe8f4949be4aa4d9512',
'com.nineoldandroids:library:68025a14e3e7673d6ad2f95e4b46d78d7d068343aa99256b686fe59de1b3163a',
@@ -306,8 +309,8 @@ android {
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
- buildConfigField "String", "GIPHY_PROXY_HOST", "\"giphy-proxy-production.whispersystems.org\""
- buildConfigField "int", "GIPHY_PROXY_PORT", "80"
+ buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
+ buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "USER_AGENT", "\"OWA\""
buildConfigField "boolean", "DEV_BUILD", "false"
buildConfigField "String", "MRENCLAVE", "\"cd6cfc342937b23b1bdd3bbf9721aa5615ac9ff50a75c5527d441cd3276826c9\""
diff --git a/res/drawable-hdpi/link_preview_splash.png b/res/drawable-hdpi/link_preview_splash.png
new file mode 100644
index 0000000000..beb7763078
Binary files /dev/null and b/res/drawable-hdpi/link_preview_splash.png differ
diff --git a/res/drawable-mdpi/link_preview_splash.png b/res/drawable-mdpi/link_preview_splash.png
new file mode 100644
index 0000000000..39d52c7f7a
Binary files /dev/null and b/res/drawable-mdpi/link_preview_splash.png differ
diff --git a/res/drawable-xhdpi/link_preview_splash.png b/res/drawable-xhdpi/link_preview_splash.png
new file mode 100644
index 0000000000..99a6f74959
Binary files /dev/null and b/res/drawable-xhdpi/link_preview_splash.png differ
diff --git a/res/drawable-xxhdpi/link_preview_splash.png b/res/drawable-xxhdpi/link_preview_splash.png
new file mode 100644
index 0000000000..691dea50b2
Binary files /dev/null and b/res/drawable-xxhdpi/link_preview_splash.png differ
diff --git a/res/drawable-xxxhdpi/link_preview_splash.png b/res/drawable-xxxhdpi/link_preview_splash.png
new file mode 100644
index 0000000000..a4192a26e9
Binary files /dev/null and b/res/drawable-xxxhdpi/link_preview_splash.png differ
diff --git a/res/layout/conversation_input_panel.xml b/res/layout/conversation_input_panel.xml
index 82d8565e48..c5d6332934 100644
--- a/res/layout/conversation_input_panel.xml
+++ b/res/layout/conversation_input_panel.xml
@@ -45,6 +45,16 @@
app:quote_colorSecondary="?attr/conversation_item_sent_text_primary_color"
tools:visibility="visible"/>
+
+
+
+
+
diff --git a/res/layout/conversation_item_sent.xml b/res/layout/conversation_item_sent.xml
index 036b92b2ea..f3ca08ebb8 100644
--- a/res/layout/conversation_item_sent.xml
+++ b/res/layout/conversation_item_sent.xml
@@ -65,6 +65,12 @@
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_sent_thumbnail" />
+
+
+
diff --git a/res/layout/experience_upgrade_link_previews_fragment.xml b/res/layout/experience_upgrade_link_previews_fragment.xml
new file mode 100644
index 0000000000..175f06b923
--- /dev/null
+++ b/res/layout/experience_upgrade_link_previews_fragment.xml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/layout/link_preview.xml b/res/layout/link_preview.xml
new file mode 100644
index 0000000000..fedd559de6
--- /dev/null
+++ b/res/layout/link_preview.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/quote_view.xml b/res/layout/quote_view.xml
index 43003a6661..4cadf034fc 100644
--- a/res/layout/quote_view.xml
+++ b/res/layout/quote_view.xml
@@ -179,9 +179,9 @@
android:id="@+id/quote_dismiss"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_marginEnd="4dp"
- android:layout_marginRight="4dp"
- android:layout_marginTop="4dp"
+ android:layout_marginEnd="6dp"
+ android:layout_marginRight="6dp"
+ android:layout_marginTop="6dp"
android:layout_gravity="top|end"
android:background="@drawable/dismiss_background"
android:src="@drawable/ic_close_white_18dp"
diff --git a/res/values/attrs.xml b/res/values/attrs.xml
index 7a7c70cfbd..d5f4082fe8 100644
--- a/res/values/attrs.xml
+++ b/res/values/attrs.xml
@@ -110,6 +110,11 @@
+
+
+
+
+
@@ -281,6 +286,13 @@
+
+
+
+
+
+
+
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 48587df74f..f96130fb2d 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -51,6 +51,8 @@
120dp
+ 4dp
+
40dp
16dp
16dp
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 2f548bdc8b..bc75a3178e 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -328,6 +328,10 @@
Turn on typing indicators
No thanks
+ Introducing link previews.
+ Optional link previews are now supported for some of the most popular sites on the Internet.
+ You can disable or enable this feature anytime in your Signal settings (Privacy > Send link previews).
+
Retrieving a message...
@@ -1149,6 +1153,8 @@
Use Signal for all incoming multimedia messages
Enter key sends
Pressing the Enter key will send text messages
+ Send link previews
+ Previews are supported for Imgur, Instagram, Reddit, and YouTube links
Choose identity
Choose your contact entry from the contacts list.
Change passphrase
diff --git a/res/values/themes.xml b/res/values/themes.xml
index 4c1541f27e..87bd077153 100644
--- a/res/values/themes.xml
+++ b/res/values/themes.xml
@@ -188,7 +188,6 @@
- @drawable/emoji_category_emoticons_light
- @drawable/emoji_variation_selector_background_light
-
- @color/core_grey_05
- @color/core_grey_90
- @color/core_grey_60
@@ -223,6 +222,11 @@
- @color/import_export_item_background_shadow_light
- @drawable/clickable_card_light
+ - @color/core_white
+ - @color/core_black
+ - @color/core_grey_60
+ - @color/core_grey_25
+
- @drawable/ic_add_white_24dp
- @drawable/ic_group_white_24dp
- @drawable/ic_search_white_24dp
@@ -373,6 +377,11 @@
- @drawable/emoji_category_emoticons_dark
- @drawable/emoji_variation_selector_background_dark
+ - @color/core_grey_95
+ - @color/core_white
+ - @color/core_grey_25
+ - @color/core_grey_60
+
- @drawable/quick_camera_dark
- @drawable/ic_mic_white_24dp
diff --git a/res/xml/preferences_app_protection.xml b/res/xml/preferences_app_protection.xml
index 74d5d10bfa..c623e5b91f 100644
--- a/res/xml/preferences_app_protection.xml
+++ b/res/xml/preferences_app_protection.xml
@@ -69,6 +69,12 @@
android:title="@string/preferences__typing_indicators"
android:summary="@string/preferences__if_typing_indicators_are_disabled_you_wont_be_able_to_see_typing_indicators"/>
+
+
diff --git a/res/xml/preferences_chats.xml b/res/xml/preferences_chats.xml
index d9407a3b97..bf176d21af 100644
--- a/res/xml/preferences_chats.xml
+++ b/res/xml/preferences_chats.xml
@@ -56,6 +56,7 @@
android:key="pref_enter_sends"
android:summary="@string/preferences__pressing_the_enter_key_will_send_text_messages"
android:title="@string/preferences__pref_enter_sends_title"/>
+
diff --git a/src/org/thoughtcrime/securesms/ApplicationContext.java b/src/org/thoughtcrime/securesms/ApplicationContext.java
index 3baf55de8e..348261a613 100644
--- a/src/org/thoughtcrime/securesms/ApplicationContext.java
+++ b/src/org/thoughtcrime/securesms/ApplicationContext.java
@@ -31,6 +31,8 @@ import com.google.android.gms.security.ProviderInstaller;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.PRNGFixes;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.AxolotlStorageModule;
import org.thoughtcrime.securesms.dependencies.InjectableType;
import org.thoughtcrime.securesms.dependencies.SignalCommunicationModule;
diff --git a/src/org/thoughtcrime/securesms/BindableConversationItem.java b/src/org/thoughtcrime/securesms/BindableConversationItem.java
index 4f853989e9..5f7fcc8270 100644
--- a/src/org/thoughtcrime/securesms/BindableConversationItem.java
+++ b/src/org/thoughtcrime/securesms/BindableConversationItem.java
@@ -7,6 +7,7 @@ import android.view.View;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.libsignal.util.guava.Optional;
@@ -31,6 +32,7 @@ public interface BindableConversationItem extends Unbindable {
interface EventListener {
void onQuoteClicked(MmsMessageRecord messageRecord);
+ void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView);
void onAddToContactsClicked(@NonNull Contact contact);
void onMessageSharedContactClicked(@NonNull List choices);
diff --git a/src/org/thoughtcrime/securesms/ConversationActivity.java b/src/org/thoughtcrime/securesms/ConversationActivity.java
index e10e8f80fa..dca05131b5 100644
--- a/src/org/thoughtcrime/securesms/ConversationActivity.java
+++ b/src/org/thoughtcrime/securesms/ConversationActivity.java
@@ -19,6 +19,7 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
+import android.arch.lifecycle.ViewModelProviders;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
@@ -125,6 +126,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
+import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
+import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob;
@@ -270,6 +274,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
protected HidingLinearLayout inlineAttachmentToggle;
private QuickAttachmentDrawer quickAttachmentDrawer;
private InputPanel inputPanel;
+ private LinkPreviewViewModel linkPreviewViewModel;
private Recipient recipient;
private long threadId;
@@ -309,6 +314,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
initializeActionBar();
initializeViews();
initializeResources();
+ initializeLinkPreviewObserver();
initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener() {
@Override
public void onSuccess(Boolean result) {
@@ -443,6 +449,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
if ((data == null && reqCode != TAKE_PHOTO && reqCode != SMS_DEFAULT) ||
(resultCode != RESULT_OK && reqCode != SMS_DEFAULT))
{
+ updateLinkPreviewState();
return;
}
@@ -516,7 +523,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
slideDeck.addSlide(new ImageSlide(this, data.getData(), imageSize, imageWidth, imageHeight));
- sendMediaMessage(transport.isSms(), message, slideDeck, Collections.emptyList(), expiresIn, subscriptionId, initiating);
+ sendMediaMessage(transport.isSms(), message, slideDeck, Collections.emptyList(), Collections.emptyList(), expiresIn, subscriptionId, initiating);
break;
case MEDIA_SENDER:
@@ -547,7 +554,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
- sendMediaMessage(transport.isSms(), message, slideDeck, Collections.emptyList(), expiresIn, subscriptionId, initiating);
+ sendMediaMessage(transport.isSms(), message, slideDeck, Collections.emptyList(), Collections.emptyList(), expiresIn, subscriptionId, initiating);
break;
}
@@ -1438,6 +1445,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
sendButton.setEnabled(true);
sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> {
calculateCharactersRemaining();
+ updateLinkPreviewState();
composeText.setTransport(newTransport);
buttonToggle.getBackground().setColorFilter(newTransport.getBackgroundColor(), Mode.MULTIPLY);
buttonToggle.getBackground().invalidateSelf();
@@ -1496,6 +1504,31 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
recipient.addListener(this);
}
+
+ private void initializeLinkPreviewObserver() {
+ linkPreviewViewModel = ViewModelProviders.of(this, new LinkPreviewViewModel.Factory(new LinkPreviewRepository())).get(LinkPreviewViewModel.class);
+
+ if (!TextSecurePreferences.isLinkPreviewsEnabled(this)) {
+ linkPreviewViewModel.onUserCancel();
+ return;
+ }
+
+ linkPreviewViewModel.getLinkPreviewState().observe(this, previewState -> {
+ if (previewState == null) return;
+
+ if (previewState.isLoading()) {
+ Log.d(TAG, "Loading link preview.");
+ inputPanel.setLinkPreviewLoading();
+ } else {
+ Log.d(TAG, "Setting link preview: " + previewState.getLinkPreview().isPresent());
+ inputPanel.setLinkPreview(glideRequests, previewState.getLinkPreview());
+ }
+
+ updateToggleButtonState();
+ });
+ }
+
+
private void initializeProfiles() {
if (!isSecureText) {
Log.i(TAG, "SMS contact, no profile fetch needed.");
@@ -1546,6 +1579,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
//////// Helper Methods
private void addAttachment(int type) {
+ linkPreviewViewModel.onUserCancel();
+
Log.i(TAG, "Selected: " + type);
switch (type) {
case AttachmentTypeSelector.ADD_GALLERY:
@@ -1604,7 +1639,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
long expiresIn = recipient.getExpireMessages() * 1000L;
boolean initiating = threadId == -1;
- sendMediaMessage(isSmsForced(), "", attachmentManager.buildSlideDeck(), contacts, expiresIn, subscriptionId, initiating);
+ sendMediaMessage(isSmsForced(), "", attachmentManager.buildSlideDeck(), contacts, Collections.emptyList(), expiresIn, subscriptionId, initiating);
}
private void selectContactInfo(ContactData contactData) {
@@ -1843,6 +1878,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
fragment.scrollToBottom();
attachmentManager.cleanup();
+
+ updateLinkPreviewState();
}
private void sendMessage() {
@@ -1857,6 +1894,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
long expiresIn = recipient.getExpireMessages() * 1000L;
boolean initiating = threadId == -1;
+ boolean isMediaMessage = attachmentManager.isAttachmentPresent() ||
+ recipient.isGroupRecipient() ||
+ recipient.getAddress().isEmail() ||
+ inputPanel.getQuote().isPresent() ||
+ linkPreviewViewModel.hasLinkPreview();
Log.i(TAG, "isManual Selection: " + sendButton.isManualSelection());
Log.i(TAG, "forceSms: " + forceSms);
@@ -1867,7 +1909,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
handleUnverifiedRecipients();
} else if (!forceSms && identityRecords.isUntrusted()) {
handleUntrustedRecipients();
- } else if (attachmentManager.isAttachmentPresent() || recipient.isGroupRecipient() || recipient.getAddress().isEmail() || inputPanel.getQuote().isPresent()) {
+ } else if (isMediaMessage) {
sendMediaMessage(forceSms, expiresIn, subscriptionId, initiating);
} else {
sendTextMessage(forceSms, expiresIn, subscriptionId, initiating);
@@ -1888,16 +1930,24 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
- sendMediaMessage(forceSms, getMessage(), attachmentManager.buildSlideDeck(), Collections.emptyList(), expiresIn, subscriptionId, initiating);
+ sendMediaMessage(forceSms, getMessage(), attachmentManager.buildSlideDeck(), Collections.emptyList(), linkPreviewViewModel.getPersistedLinkPreviews(this), expiresIn, subscriptionId, initiating);
}
- private ListenableFuture sendMediaMessage(final boolean forceSms, String body, SlideDeck slideDeck, List contacts, final long expiresIn, final int subscriptionId, final boolean initiating) {
+ private ListenableFuture sendMediaMessage(final boolean forceSms,
+ String body,
+ SlideDeck slideDeck,
+ List contacts,
+ List previews,
+ final long expiresIn,
+ final int subscriptionId,
+ final boolean initiating)
+ {
if (!isDefaultSms && (!isSecureText || forceSms)) {
showDefaultSmsPrompt();
return new SettableFuture<>(null);
}
- OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient, slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, distributionType, inputPanel.getQuote().orNull(), contacts);
+ OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient, slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, distributionType, inputPanel.getQuote().orNull(), contacts, previews);
final SettableFuture future = new SettableFuture<>();
final Context context = getApplicationContext();
@@ -2009,7 +2059,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
buttonToggle.display(sendButton);
quickAttachmentToggle.hide();
- if (!attachmentManager.isAttachmentPresent()) {
+ if (!attachmentManager.isAttachmentPresent() && !linkPreviewViewModel.hasLinkPreview()) {
inlineAttachmentToggle.show();
} else {
inlineAttachmentToggle.hide();
@@ -2017,6 +2067,15 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
+ private void updateLinkPreviewState() {
+ if (TextSecurePreferences.isLinkPreviewsEnabled(this) && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) {
+ linkPreviewViewModel.onEnabled();
+ linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed());
+ } else {
+ linkPreviewViewModel.onUserCancel();
+ }
+ }
+
private void recordSubscriptionIdPreference(final Optional subscriptionId) {
new AsyncTask() {
@Override
@@ -2104,7 +2163,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
SlideDeck slideDeck = new SlideDeck();
slideDeck.addSlide(audioSlide);
- sendMediaMessage(forceSms, "", slideDeck, Collections.emptyList(), expiresIn, subscriptionId, initiating).addListener(new AssertedSuccessListener() {
+ sendMediaMessage(forceSms, "", slideDeck, Collections.emptyList(), Collections.emptyList(), expiresIn, subscriptionId, initiating).addListener(new AssertedSuccessListener() {
@Override
public void onSuccess(Void nothing) {
new AsyncTask() {
@@ -2164,6 +2223,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
+ @Override
+ public void onLinkPreviewCanceled() {
+ linkPreviewViewModel.onUserCancel();
+ }
+
@Override
public void onMediaSelected(@NonNull Uri uri, String contentType) {
if (!TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif")) {
@@ -2193,6 +2257,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
@Override
public void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height) {
+ linkPreviewViewModel.onUserCancel();
Media media = new Media(uri, mimeType, dateTaken, width, height, Optional.of(Media.ALL_MEDIA_BUCKET_ID), Optional.absent());
startActivityForResult(MediaSendActivity.getIntent(ConversationActivity.this, Collections.singletonList(media), recipient, composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
}
@@ -2278,7 +2343,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
public void afterTextChanged(Editable s) {
calculateCharactersRemaining();
- if (composeText.getTextTrimmed().length() == 0 || beforeLength == 0) {
+ String trimmed = composeText.getTextTrimmed();
+
+ linkPreviewViewModel.onTextChanged(ConversationActivity.this, trimmed);
+
+ if (trimmed.length() == 0 || beforeLength == 0) {
composeText.postDelayed(ConversationActivity.this::updateToggleButtonState, 50);
}
}
@@ -2336,6 +2405,21 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
author,
body,
slideDeck);
+
+ } else if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getLinkPreviews().isEmpty()) {
+ LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
+ SlideDeck slideDeck = new SlideDeck();
+
+ if (linkPreview.getThumbnail().isPresent()) {
+ slideDeck.addSlide(MediaUtil.getSlideForAttachment(this, linkPreview.getThumbnail().get()));
+ }
+
+ inputPanel.setQuote(GlideApp.with(this),
+ messageRecord.getDateSent(),
+ author,
+ messageRecord.getBody(),
+ slideDeck);
+
} else {
inputPanel.setQuote(GlideApp.with(this),
messageRecord.getDateSent(),
@@ -2349,6 +2433,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
public void onAttachmentChanged() {
handleSecurityChange(isSecureText, isDefaultSms);
updateToggleButtonState();
+ updateLinkPreviewState();
}
private class UnverifiedDismissedListener implements UnverifiedBannerView.DismissListener {
diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java
index 19284a1f0f..783648321f 100644
--- a/src/org/thoughtcrime/securesms/ConversationFragment.java
+++ b/src/org/thoughtcrime/securesms/ConversationFragment.java
@@ -44,6 +44,7 @@ import android.text.TextUtils;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.ConversationTypingView;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import android.view.LayoutInflater;
@@ -879,6 +880,13 @@ public class ConversationFragment extends Fragment
}.execute();
}
+ @Override
+ public void onLinkPreviewClicked(@NonNull LinkPreview linkPreview) {
+ if (getContext() != null && getActivity() != null) {
+ CommunicationActions.openBrowserLink(getActivity(), linkPreview.getUrl());
+ }
+ }
+
@Override
public void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView) {
if (getContext() != null && getActivity() != null) {
diff --git a/src/org/thoughtcrime/securesms/ConversationItem.java b/src/org/thoughtcrime/securesms/ConversationItem.java
index 0aa42ae246..393bf44ad6 100644
--- a/src/org/thoughtcrime/securesms/ConversationItem.java
+++ b/src/org/thoughtcrime/securesms/ConversationItem.java
@@ -34,6 +34,10 @@ import android.text.TextUtils;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.AttributeSet;
+
+import org.thoughtcrime.securesms.attachments.Attachment;
+import org.thoughtcrime.securesms.components.LinkPreviewView;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import android.util.TypedValue;
import android.view.View;
@@ -66,6 +70,7 @@ import org.thoughtcrime.securesms.jobs.MmsDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsSendJob;
import org.thoughtcrime.securesms.jobs.SmsSendJob;
import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
@@ -127,6 +132,7 @@ public class ConversationItem extends LinearLayout
private @NonNull Stub audioViewStub;
private @NonNull Stub documentViewStub;
private @NonNull Stub sharedContactStub;
+ private @NonNull Stub linkPreviewStub;
private @Nullable EventListener eventListener;
private int defaultBubbleColor;
@@ -137,6 +143,7 @@ public class ConversationItem extends LinearLayout
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
+ private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
private final Context context;
@@ -172,6 +179,7 @@ public class ConversationItem extends LinearLayout
this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub));
this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub));
this.sharedContactStub = new Stub<>(findViewById(R.id.shared_contact_view_stub));
+ this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub));
this.groupSenderHolder = findViewById(R.id.group_sender_holder);
this.quoteView = findViewById(R.id.quote_view);
this.container = findViewById(R.id.container);
@@ -383,6 +391,10 @@ public class ConversationItem extends LinearLayout
return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getSharedContacts().isEmpty();
}
+ private boolean hasLinkPreview(MessageRecord messageRecord) {
+ return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getLinkPreviews().isEmpty();
+ }
+
private void setBodyText(MessageRecord messageRecord) {
bodyText.setClickable(false);
bodyText.setFocusable(false);
@@ -409,6 +421,7 @@ public class ConversationItem extends LinearLayout
if (audioViewStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
+ if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
sharedContactStub.get().setEventListener(sharedContactEventListener);
@@ -418,13 +431,51 @@ public class ConversationItem extends LinearLayout
setSharedContactCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
footer.setVisibility(GONE);
+ } else if (hasLinkPreview(messageRecord)) {
+ linkPreviewStub.get().setVisibility(View.VISIBLE);
+ if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
+ if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
+ if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
+ if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
+
+ //noinspection ConstantConditions
+ LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
+
+ if (linkPreview.getThumbnail().isPresent() && shouldPromotePreviewImage(linkPreview.getThumbnail().get())) {
+ mediaThumbnailStub.get().setVisibility(VISIBLE);
+ mediaThumbnailStub.get().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(context, linkPreview.getThumbnail().get())), showControls, false);
+ mediaThumbnailStub.get().setThumbnailClickListener(new LinkPreviewThumbnailClickListener());
+ mediaThumbnailStub.get().setDownloadClickListener(downloadClickListener);
+ mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener);
+
+ linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, false);
+
+ setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
+ setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, true);
+
+ ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ } else {
+ linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, true);
+ linkPreviewStub.get().setDownloadClickedListener(downloadClickListener);
+ setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, false);
+ ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ }
+
+ linkPreviewStub.get().setOnClickListener(linkPreviewClickListener);
+ linkPreviewStub.get().setOnLongClickListener(passthroughClickListener);
+
+
+ footer.setVisibility(VISIBLE);
} else if (hasAudio(messageRecord)) {
audioViewStub.get().setVisibility(View.VISIBLE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
+ if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
//noinspection ConstantConditions
audioViewStub.get().setAudio(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide(), showControls);
@@ -439,6 +490,7 @@ public class ConversationItem extends LinearLayout
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
+ if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
//noinspection ConstantConditions
documentViewStub.get().setDocument(((MediaMmsMessageRecord)messageRecord).getSlideDeck().getDocumentSlide(), showControls);
@@ -451,9 +503,10 @@ public class ConversationItem extends LinearLayout
footer.setVisibility(VISIBLE);
} else if (hasThumbnail(messageRecord)) {
mediaThumbnailStub.get().setVisibility(View.VISIBLE);
- if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
- if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
- if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
+ if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
+ if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
+ if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
+ if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
//noinspection ConstantConditions
List thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
@@ -469,7 +522,7 @@ public class ConversationItem extends LinearLayout
mediaThumbnailStub.get().setConversationColor(messageRecord.isOutgoing() ? defaultBubbleColor
: messageRecord.getRecipient().getColor().toConversationColor(context));
- setThumbnailOutlineCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
+ setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
@@ -479,6 +532,7 @@ public class ConversationItem extends LinearLayout
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
+ if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
@@ -486,10 +540,10 @@ public class ConversationItem extends LinearLayout
}
}
- private void setThumbnailOutlineCorners(@NonNull MessageRecord current,
- @NonNull Optional previous,
- @NonNull Optional next,
- boolean isGroupThread)
+ private void setThumbnailCorners(@NonNull MessageRecord current,
+ @NonNull Optional previous,
+ @NonNull Optional next,
+ boolean isGroupThread)
{
int defaultRadius = readDimen(R.dimen.message_corner_radius);
int collapseRadius = readDimen(R.dimen.message_corner_collapse_radius);
@@ -541,18 +595,38 @@ public class ConversationItem extends LinearLayout
topRight = 0;
}
- mediaThumbnailStub.get().setOutlineCorners(topLeft, topRight, bottomRight, bottomLeft);
+ if (hasLinkPreview(messageRecord)) {
+ bottomLeft = 0;
+ bottomRight = 0;
+ }
+
+ mediaThumbnailStub.get().setCorners(topLeft, topRight, bottomRight, bottomLeft);
}
private void setSharedContactCorners(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread) {
if (isSingularMessage(current, previous, next, isGroupThread) || isEndOfMessageCluster(current, next, isGroupThread)) {
sharedContactStub.get().setSingularStyle();
+ } else if (current.isOutgoing()) {
+ sharedContactStub.get().setClusteredOutgoingStyle();
} else {
- if (current.isOutgoing()) {
- sharedContactStub.get().setClusteredOutgoingStyle();
- } else {
- sharedContactStub.get().setClusteredIncomingStyle();
- }
+ sharedContactStub.get().setClusteredIncomingStyle();
+ }
+ }
+
+ private void setLinkPreviewCorners(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread, boolean bigImage) {
+ int defaultRadius = readDimen(R.dimen.message_corner_radius);
+ int collapseRadius = readDimen(R.dimen.message_corner_collapse_radius);
+
+ if (bigImage) {
+ linkPreviewStub.get().setCorners(0, 0);
+ } else if (isStartOfMessageCluster(current, previous, isGroupThread) && !current.isOutgoing() && isGroupThread) {
+ linkPreviewStub.get().setCorners(0, 0);
+ } else if (isSingularMessage(current, previous, next, isGroupThread) || isStartOfMessageCluster(current, previous, isGroupThread)) {
+ linkPreviewStub.get().setCorners(defaultRadius, defaultRadius);
+ } else if (current.isOutgoing()) {
+ linkPreviewStub.get().setCorners(defaultRadius, collapseRadius);
+ } else {
+ linkPreviewStub.get().setCorners(collapseRadius, defaultRadius);
}
}
@@ -561,6 +635,11 @@ public class ConversationItem extends LinearLayout
contactPhoto.setAvatar(glideRequests, recipient, true);
}
+ private boolean shouldPromotePreviewImage(@NonNull Attachment attachment) {
+ int minWidth = getResources().getDimensionPixelSize(R.dimen.media_bubble_min_width);
+ return attachment.getWidth() >= minWidth;
+ }
+
private SpannableString linkifyMessageBody(SpannableString messageBody, boolean shouldLinkifyAllLinks) {
int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS;
boolean hasLinks = Linkify.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0);
@@ -847,6 +926,27 @@ public class ConversationItem extends LinearLayout
}
}
+ private class LinkPreviewClickListener implements View.OnClickListener {
+ @Override
+ public void onClick(View view) {
+ if (eventListener != null && batchSelected.isEmpty() && messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getLinkPreviews().isEmpty()) {
+ eventListener.onLinkPreviewClicked(((MmsMessageRecord) messageRecord).getLinkPreviews().get(0));
+ } else {
+ passthroughClickListener.onClick(view);
+ }
+ }
+ }
+
+ private class LinkPreviewThumbnailClickListener implements SlideClickListener {
+ public void onClick(final View v, final Slide slide) {
+ if (eventListener != null && batchSelected.isEmpty() && messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getLinkPreviews().isEmpty()) {
+ eventListener.onLinkPreviewClicked(((MmsMessageRecord) messageRecord).getLinkPreviews().get(0));
+ } else {
+ performClick();
+ }
+ }
+ }
+
private class AttachmentDownloadClickListener implements SlidesClickedListener {
@Override
public void onClick(View v, final List slides) {
diff --git a/src/org/thoughtcrime/securesms/ConversationListActivity.java b/src/org/thoughtcrime/securesms/ConversationListActivity.java
index 2e533e5c19..29bc3c58e2 100644
--- a/src/org/thoughtcrime/securesms/ConversationListActivity.java
+++ b/src/org/thoughtcrime/securesms/ConversationListActivity.java
@@ -48,7 +48,6 @@ import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.lock.RegistrationLockDialog;
-import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
@@ -60,7 +59,7 @@ import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
-import org.thoughtcrime.securesms.util.concurrent.LifecycleBoundTask;
+import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
@@ -112,7 +111,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
dynamicTheme.onResume(this);
dynamicLanguage.onResume(this);
- LifecycleBoundTask.run(getLifecycle(), () -> {
+ SimpleTask.run(getLifecycle(), () -> {
return Recipient.from(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)), false);
}, this::initializeProfileIcon);
}
diff --git a/src/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java b/src/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java
index 33b8342e4e..d74c9e723a 100644
--- a/src/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java
+++ b/src/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java
@@ -29,7 +29,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.List;
-public class ExperienceUpgradeActivity extends BaseActionBarActivity implements TypingIndicatorIntroFragment.Controller {
+public class ExperienceUpgradeActivity extends BaseActionBarActivity implements TypingIndicatorIntroFragment.Controller, LinkPreviewsIntroFragment.Controller {
private static final String TAG = ExperienceUpgradeActivity.class.getSimpleName();
private static final String DISMISS_ACTION = "org.thoughtcrime.securesms.ExperienceUpgradeActivity.DISMISS_ACTION";
private static final int NOTIFICATION_ID = 1339;
@@ -80,7 +80,14 @@ public class ExperienceUpgradeActivity extends BaseActionBarActivity implements
R.string.ExperienceUpgradeActivity_now_you_can_optionally_see_and_share_when_messages_are_being_typed,
R.string.ExperienceUpgradeActivity_now_you_can_optionally_see_and_share_when_messages_are_being_typed,
null,
- true);
+ true),
+ LINK_PREVIEWS(449,
+ new IntroPage(0xFF2090EA, LinkPreviewsIntroFragment.newInstance()),
+ R.string.ExperienceUpgradeActivity_introducing_link_previews,
+ R.string.ExperienceUpgradeActivity_optional_link_previews_are_now_supported,
+ R.string.ExperienceUpgradeActivity_optional_link_previews_are_now_supported,
+ null,
+ true);
private int version;
private List pages;
@@ -215,10 +222,15 @@ public class ExperienceUpgradeActivity extends BaseActionBarActivity implements
}
@Override
- public void onFinished() {
+ public void onTypingIndicatorsFinished() {
onContinue(Optional.of(ExperienceUpgrade.TYPING_INDICATORS));
}
+ @Override
+ public void onLinkPreviewsFinished() {
+ onContinue(Optional.of(ExperienceUpgrade.LINK_PREVIEWS));
+ }
+
private final class OnPageChangeListener implements ViewPager.OnPageChangeListener {
private final ArgbEvaluator evaluator = new ArgbEvaluator();
private final ExperienceUpgrade upgrade;
diff --git a/src/org/thoughtcrime/securesms/LinkPreviewsIntroFragment.java b/src/org/thoughtcrime/securesms/LinkPreviewsIntroFragment.java
new file mode 100644
index 0000000000..632f748688
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/LinkPreviewsIntroFragment.java
@@ -0,0 +1,65 @@
+package org.thoughtcrime.securesms;
+
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.thoughtcrime.securesms.components.TypingIndicatorView;
+import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+public class LinkPreviewsIntroFragment extends Fragment {
+
+ private Controller controller;
+
+ public static LinkPreviewsIntroFragment newInstance() {
+ LinkPreviewsIntroFragment fragment = new LinkPreviewsIntroFragment();
+ Bundle args = new Bundle();
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ public LinkPreviewsIntroFragment() {}
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+
+ if (!(getActivity() instanceof Controller)) {
+ throw new IllegalStateException("Parent activity must implement the Controller interface.");
+ }
+
+ controller = (Controller) getActivity();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.experience_upgrade_link_previews_fragment, container, false);
+
+ view.findViewById(R.id.experience_ok_button).setOnClickListener(v -> {
+ ApplicationContext.getInstance(requireContext())
+ .getJobManager()
+ .add(new MultiDeviceConfigurationUpdateJob(getContext(),
+ TextSecurePreferences.isReadReceiptsEnabled(requireContext()),
+ TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()),
+ TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(requireContext()),
+ TextSecurePreferences.isLinkPreviewsEnabled(requireContext())));
+ controller.onLinkPreviewsFinished();
+ });
+
+ return view;
+ }
+
+ public interface Controller {
+ void onLinkPreviewsFinished();
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java b/src/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java
index 4f2649451f..0d62ef5da5 100644
--- a/src/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java
+++ b/src/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java
@@ -41,7 +41,8 @@ public class ReadReceiptsIntroFragment extends Fragment {
.add(new MultiDeviceConfigurationUpdateJob(getContext(),
isChecked,
TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()),
- TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext())));
+ TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
+ TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
});
return v;
diff --git a/src/org/thoughtcrime/securesms/TransportOptions.java b/src/org/thoughtcrime/securesms/TransportOptions.java
index 26eb16efce..674028f54e 100644
--- a/src/org/thoughtcrime/securesms/TransportOptions.java
+++ b/src/org/thoughtcrime/securesms/TransportOptions.java
@@ -62,6 +62,10 @@ public class TransportOptions {
}
public void setDefaultSubscriptionId(Optional subscriptionId) {
+ if (defaultSubscriptionId.equals(subscriptionId)) {
+ return;
+ }
+
this.defaultSubscriptionId = subscriptionId;
if (!selectedOption.isPresent()) {
diff --git a/src/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java b/src/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java
index 8ec497c9ec..cb92db73b8 100644
--- a/src/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java
+++ b/src/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java
@@ -4,7 +4,6 @@ package org.thoughtcrime.securesms;
import android.content.Context;
import android.os.Bundle;
import android.support.v4.app.Fragment;
-import android.support.v7.widget.SwitchCompat;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -12,7 +11,6 @@ import android.view.ViewGroup;
import org.thoughtcrime.securesms.components.TypingIndicatorView;
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
-import org.thoughtcrime.securesms.util.ViewUtil;
public class TypingIndicatorIntroFragment extends Fragment {
@@ -64,12 +62,13 @@ public class TypingIndicatorIntroFragment extends Fragment {
.add(new MultiDeviceConfigurationUpdateJob(getContext(),
TextSecurePreferences.isReadReceiptsEnabled(requireContext()),
typingEnabled,
- TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext())));
+ TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
+ TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
- controller.onFinished();
+ controller.onTypingIndicatorsFinished();
}
public interface Controller {
- void onFinished();
+ void onTypingIndicatorsFinished();
}
}
diff --git a/src/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/src/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java
index 048780897f..f225a9d646 100644
--- a/src/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java
+++ b/src/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java
@@ -26,24 +26,12 @@ import java.util.List;
public class ConversationItemThumbnail extends FrameLayout {
- private static final String TAG = ConversationItemThumbnail.class.getSimpleName();
-
- private final float[] radii = new float[8];
- private final RectF bounds = new RectF();
- private final Path corners = new Path();
-
private ThumbnailView thumbnail;
private AlbumThumbnailView album;
private ImageView shade;
private ConversationItemFooter footer;
private CornerMask cornerMask;
-
- private final Paint outlinePaint = new Paint();
- {
- outlinePaint.setStyle(Paint.Style.STROKE);
- outlinePaint.setStrokeWidth(1f);
- outlinePaint.setAntiAlias(true);
- }
+ private Outliner outliner;
public ConversationItemThumbnail(Context context) {
super(context);
@@ -63,13 +51,14 @@ public class ConversationItemThumbnail extends FrameLayout {
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.conversation_item_thumbnail, this);
- outlinePaint.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
-
this.thumbnail = findViewById(R.id.conversation_thumbnail_image);
this.album = findViewById(R.id.conversation_thumbnail_album);
this.shade = findViewById(R.id.conversation_thumbnail_shade);
this.footer = findViewById(R.id.conversation_thumbnail_footer);
this.cornerMask = new CornerMask(this);
+ this.outliner = new Outliner();
+
+ outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0);
@@ -95,17 +84,7 @@ public class ConversationItemThumbnail extends FrameLayout {
}
if (album.getVisibility() != VISIBLE) {
- final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2;
-
- bounds.left = halfStrokeWidth;
- bounds.top = halfStrokeWidth;
- bounds.right = canvas.getWidth() - halfStrokeWidth;
- bounds.bottom = canvas.getHeight() - halfStrokeWidth;
-
- corners.reset();
- corners.addRoundRect(bounds, radii, Path.Direction.CW);
-
- canvas.drawPath(corners, outlinePaint);
+ outliner.draw(canvas);
}
}
@@ -132,13 +111,9 @@ public class ConversationItemThumbnail extends FrameLayout {
forceLayout();
}
- public void setOutlineCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
- radii[0] = radii[1] = topLeft;
- radii[2] = radii[3] = topRight;
- radii[4] = radii[5] = bottomRight;
- radii[6] = radii[7] = bottomLeft;
-
+ public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
+ outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
}
public ConversationItemFooter getFooter() {
diff --git a/src/org/thoughtcrime/securesms/components/InputPanel.java b/src/org/thoughtcrime/securesms/components/InputPanel.java
index 031acc46b0..017fddfbf9 100644
--- a/src/org/thoughtcrime/securesms/components/InputPanel.java
+++ b/src/org/thoughtcrime/securesms/components/InputPanel.java
@@ -4,6 +4,7 @@ import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
+import android.support.annotation.DimenRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.view.ViewCompat;
@@ -22,6 +23,7 @@ import android.widget.Toast;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiDrawer;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
@@ -48,13 +50,14 @@ public class InputPanel extends LinearLayout
private static final int FADE_TIME = 150;
- private QuoteView quoteView;
- private EmojiToggle emojiToggle;
- private ComposeText composeText;
- private View quickCameraToggle;
- private View quickAudioToggle;
- private View buttonToggle;
- private View recordingContainer;
+ private QuoteView quoteView;
+ private LinkPreviewView linkPreview;
+ private EmojiToggle emojiToggle;
+ private ComposeText composeText;
+ private View quickCameraToggle;
+ private View quickAudioToggle;
+ private View buttonToggle;
+ private View recordingContainer;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
@@ -83,6 +86,7 @@ public class InputPanel extends LinearLayout
View quoteDismiss = findViewById(R.id.quote_dismiss);
this.quoteView = findViewById(R.id.quote_view);
+ this.linkPreview = findViewById(R.id.link_preview);
this.emojiToggle = findViewById(R.id.emoji_toggle);
this.composeText = findViewById(R.id.embedded_text_editor);
this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
@@ -108,6 +112,12 @@ public class InputPanel extends LinearLayout
}
quoteDismiss.setOnClickListener(v -> clearQuote());
+
+ linkPreview.setCloseClickedListener(() -> {
+ if (listener != null) {
+ listener.onLinkPreviewCanceled();
+ }
+ });
}
public void setListener(final @NonNull Listener listener) {
@@ -123,10 +133,20 @@ public class InputPanel extends LinearLayout
public void setQuote(@NonNull GlideRequests glideRequests, long id, @NonNull Recipient author, @NonNull String body, @NonNull SlideDeck attachments) {
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments);
this.quoteView.setVisibility(View.VISIBLE);
+
+ if (this.linkPreview.getVisibility() == View.VISIBLE) {
+ int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
+ this.linkPreview.setCorners(cornerRadius, cornerRadius);
+ }
}
public void clearQuote() {
this.quoteView.dismiss();
+
+ if (this.linkPreview.getVisibility() == View.VISIBLE) {
+ int cornerRadius = readDimen(R.dimen.message_corner_radius);
+ this.linkPreview.setCorners(cornerRadius, cornerRadius);
+ }
}
public Optional getQuote() {
@@ -137,6 +157,25 @@ public class InputPanel extends LinearLayout
}
}
+ public void setLinkPreviewLoading() {
+ this.linkPreview.setVisibility(View.VISIBLE);
+ this.linkPreview.setLoading();
+ }
+
+ public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional preview) {
+ if (preview.isPresent()) {
+ this.linkPreview.setVisibility(View.VISIBLE);
+ this.linkPreview.setLinkPreview(glideRequests, preview.get(), true);
+ } else {
+ this.linkPreview.setVisibility(View.GONE);
+ }
+
+ int cornerRadius = quoteView.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius)
+ : readDimen(R.dimen.message_corner_radius);
+
+ this.linkPreview.setCorners(cornerRadius, cornerRadius);
+ }
+
public void setEmojiDrawer(@NonNull EmojiDrawer emojiDrawer) {
emojiToggle.attach(emojiDrawer);
}
@@ -238,6 +277,10 @@ public class InputPanel extends LinearLayout
composeText.insertEmoji(emoji);
}
+ private int readDimen(@DimenRes int dimenRes) {
+ return getResources().getDimensionPixelSize(dimenRes);
+ }
+
public interface Listener {
void onRecorderStarted();
@@ -245,6 +288,7 @@ public class InputPanel extends LinearLayout
void onRecorderCanceled();
void onRecorderPermissionRequired();
void onEmojiToggle();
+ void onLinkPreviewCanceled();
}
private static class SlideToCancel {
diff --git a/src/org/thoughtcrime/securesms/components/LinkPreviewView.java b/src/org/thoughtcrime/securesms/components/LinkPreviewView.java
new file mode 100644
index 0000000000..befbe904ec
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/components/LinkPreviewView.java
@@ -0,0 +1,160 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.mms.ImageSlide;
+import org.thoughtcrime.securesms.mms.SlidesClickedListener;
+import org.thoughtcrime.securesms.util.ThemeUtil;
+
+import okhttp3.HttpUrl;
+
+public class LinkPreviewView extends FrameLayout {
+
+ private static final int TYPE_CONVERSATION = 0;
+ private static final int TYPE_COMPOSE = 1;
+
+ private ViewGroup container;
+ private OutlinedThumbnailView thumbnail;
+ private TextView title;
+ private TextView site;
+ private View divider;
+ private View closeButton;
+ private View spinner;
+
+ private int type;
+ private int defaultRadius;
+ private CornerMask cornerMask;
+ private Outliner outliner;
+ private CloseClickedListener closeClickedListener;
+
+ public LinkPreviewView(Context context) {
+ super(context);
+ init(null);
+ }
+
+ public LinkPreviewView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs);
+ }
+
+ private void init(@Nullable AttributeSet attrs) {
+ inflate(getContext(), R.layout.link_preview, this);
+
+ container = findViewById(R.id.linkpreview_container);
+ thumbnail = findViewById(R.id.linkpreview_thumbnail);
+ title = findViewById(R.id.linkpreview_title);
+ site = findViewById(R.id.linkpreview_site);
+ divider = findViewById(R.id.linkpreview_divider);
+ spinner = findViewById(R.id.linkpreview_progress_wheel);
+ closeButton = findViewById(R.id.linkpreview_close);
+ defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius);
+ cornerMask = new CornerMask(this);
+ outliner = new Outliner();
+
+ outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
+
+ if (attrs != null) {
+ TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LinkPreviewView, 0, 0);
+ type = typedArray.getInt(R.styleable.LinkPreviewView_linkpreview_type, 0);
+ typedArray.recycle();
+ }
+
+ if (type == TYPE_COMPOSE) {
+ container.setBackgroundColor(Color.TRANSPARENT);
+ container.setPadding(0, 0, 0, 0);
+ divider.setVisibility(VISIBLE);
+ closeButton.setVisibility(VISIBLE);
+
+ closeButton.setOnClickListener(v -> {
+ if (closeClickedListener != null) {
+ closeClickedListener.onCloseClicked();
+ }
+ });
+ }
+
+ setWillNotDraw(false);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ if (type == TYPE_COMPOSE) return;
+
+ if (cornerMask.isLegacy()) {
+ cornerMask.mask(canvas);
+ }
+ }
+
+ @Override
+ protected void dispatchDraw(Canvas canvas) {
+ super.dispatchDraw(canvas);
+ if (type == TYPE_COMPOSE) return;
+
+ if (!cornerMask.isLegacy()) {
+ cornerMask.mask(canvas);
+ }
+
+ outliner.draw(canvas);
+ }
+
+ public void setLoading() {
+ title.setVisibility(GONE);
+ site.setVisibility(GONE);
+ thumbnail.setVisibility(GONE);
+ spinner.setVisibility(VISIBLE);
+ }
+
+ public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
+ title.setVisibility(VISIBLE);
+ site.setVisibility(VISIBLE);
+ thumbnail.setVisibility(VISIBLE);
+ spinner.setVisibility(GONE);
+
+ title.setText(linkPreview.getTitle());
+
+ HttpUrl url = HttpUrl.parse(linkPreview.getUrl());
+ if (url != null) {
+ site.setText(url.topPrivateDomain());
+ }
+
+ if (showThumbnail && linkPreview.getThumbnail().isPresent()) {
+ thumbnail.setVisibility(VISIBLE);
+ thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
+ thumbnail.showDownloadText(false);
+ } else {
+ thumbnail.setVisibility(GONE);
+ }
+ }
+
+ public void setCorners(int topLeft, int topRight) {
+ cornerMask.setRadii(topLeft, topRight, 0, 0);
+ outliner.setRadii(topLeft, topRight, 0, 0);
+ thumbnail.setCorners(topLeft, defaultRadius, defaultRadius, defaultRadius);
+ postInvalidate();
+ }
+
+ public void setCloseClickedListener(@Nullable CloseClickedListener closeClickedListener) {
+ this.closeClickedListener = closeClickedListener;
+ }
+
+ public void setDownloadClickedListener(SlidesClickedListener listener) {
+ thumbnail.setDownloadClickListener(listener);
+ }
+
+ public interface CloseClickedListener {
+ void onCloseClicked();
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java b/src/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java
new file mode 100644
index 0000000000..d01cfb32b7
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java
@@ -0,0 +1,93 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.support.annotation.UiThread;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+
+import com.bumptech.glide.RequestBuilder;
+import com.bumptech.glide.load.engine.DiskCacheStrategy;
+import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
+import com.bumptech.glide.load.resource.bitmap.CenterCrop;
+import com.bumptech.glide.load.resource.bitmap.FitCenter;
+import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
+import com.bumptech.glide.request.RequestOptions;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
+import org.thoughtcrime.securesms.mms.GlideRequest;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.mms.Slide;
+import org.thoughtcrime.securesms.mms.SlideClickListener;
+import org.thoughtcrime.securesms.mms.SlidesClickedListener;
+import org.thoughtcrime.securesms.util.ThemeUtil;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.ViewUtil;
+import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
+import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.Collections;
+import java.util.Locale;
+
+import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
+
+public class OutlinedThumbnailView extends ThumbnailView {
+
+ private CornerMask cornerMask;
+ private Outliner outliner;
+
+ public OutlinedThumbnailView(Context context) {
+ super(context);
+ init();
+ }
+
+ public OutlinedThumbnailView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ cornerMask = new CornerMask(this);
+ outliner = new Outliner();
+
+ outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
+ setRadius(0);
+ setWillNotDraw(false);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (cornerMask.isLegacy()) {
+ cornerMask.mask(canvas);
+ }
+ }
+
+ @Override
+ protected void dispatchDraw(Canvas canvas) {
+ super.dispatchDraw(canvas);
+
+ if (!cornerMask.isLegacy()) {
+ cornerMask.mask(canvas);
+ }
+
+ outliner.draw(canvas);
+ }
+
+ public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
+ cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
+ outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
+ postInvalidate();
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/components/Outliner.java b/src/org/thoughtcrime/securesms/components/Outliner.java
new file mode 100644
index 0000000000..e2c8e71d26
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/components/Outliner.java
@@ -0,0 +1,55 @@
+package org.thoughtcrime.securesms.components;
+
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.RectF;
+import android.os.Build;
+import android.support.annotation.ColorInt;
+import android.support.annotation.NonNull;
+import android.view.View;
+
+public class Outliner {
+
+ private final float[] radii = new float[8];
+ private final Path corners = new Path();
+ private final RectF bounds = new RectF();
+ private final Paint outlinePaint = new Paint();
+ {
+ outlinePaint.setStyle(Paint.Style.STROKE);
+ outlinePaint.setStrokeWidth(1f);
+ outlinePaint.setAntiAlias(true);
+ }
+
+ public void setColor(@ColorInt int color) {
+ outlinePaint.setColor(color);
+ }
+
+ public void draw(Canvas canvas) {
+ final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2;
+
+ bounds.left = halfStrokeWidth;
+ bounds.top = halfStrokeWidth;
+ bounds.right = canvas.getWidth() - halfStrokeWidth;
+ bounds.bottom = canvas.getHeight() - halfStrokeWidth;
+
+ corners.reset();
+ corners.addRoundRect(bounds, radii, Path.Direction.CW);
+
+ canvas.drawPath(corners, outlinePaint);
+ }
+
+ public void setRadius(int radius) {
+ setRadii(radius, radius, radius, radius);
+ }
+
+ public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
+ radii[0] = radii[1] = topLeft;
+ radii[2] = radii[3] = topRight;
+ radii[4] = radii[5] = bottomRight;
+ radii[6] = radii[7] = bottomLeft;
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/components/ThumbnailView.java b/src/org/thoughtcrime/securesms/components/ThumbnailView.java
index a6a5a26810..5c65b367df 100644
--- a/src/org/thoughtcrime/securesms/components/ThumbnailView.java
+++ b/src/org/thoughtcrime/securesms/components/ThumbnailView.java
@@ -89,12 +89,11 @@ public class ThumbnailView extends FrameLayout {
bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0);
bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0);
bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0);
- radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius));
+ radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius));
typedArray.recycle();
} else {
radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
}
-
}
@Override
@@ -329,10 +328,18 @@ public class ThumbnailView extends FrameLayout {
slide = null;
}
+ public void showDownloadText(boolean showDownloadText) {
+ getTransferControls().setShowDownloadText(showDownloadText);
+ }
+
public void showProgressSpinner() {
getTransferControls().showProgressSpinner();
}
+ protected void setRadius(int radius) {
+ this.radius = radius;
+ }
+
private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri()))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
diff --git a/src/org/thoughtcrime/securesms/components/TransferControlView.java b/src/org/thoughtcrime/securesms/components/TransferControlView.java
index 602145d90c..3f8cb4f9ef 100644
--- a/src/org/thoughtcrime/securesms/components/TransferControlView.java
+++ b/src/org/thoughtcrime/securesms/components/TransferControlView.java
@@ -170,6 +170,7 @@ public class TransferControlView extends FrameLayout {
public void setShowDownloadText(boolean showDownloadText) {
downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE);
+ forceLayout();
}
private boolean isUpdateToExistingSet(@NonNull List slides) {
diff --git a/src/org/thoughtcrime/securesms/database/DatabaseFactory.java b/src/org/thoughtcrime/securesms/database/DatabaseFactory.java
index 8709e486d2..b713462706 100644
--- a/src/org/thoughtcrime/securesms/database/DatabaseFactory.java
+++ b/src/org/thoughtcrime/securesms/database/DatabaseFactory.java
@@ -144,6 +144,10 @@ public class DatabaseFactory {
getInstance(context).databaseHelper.markCurrent(database);
}
+ public void doThing(Context context) {
+ getInstance(context).databaseHelper.getReadableDatabase().execSQL("ALTER TABLE mms ADD COLUMN previews TEXT");
+ }
+
private DatabaseFactory(@NonNull Context context) {
SQLiteDatabase.loadLibs(context);
diff --git a/src/org/thoughtcrime/securesms/database/MmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsDatabase.java
index 4be3313b7a..5dcd36343f 100644
--- a/src/org/thoughtcrime/securesms/database/MmsDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/MmsDatabase.java
@@ -25,6 +25,7 @@ import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.util.Pair;
+import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.mms.pdu_alt.NotificationInd;
import com.google.android.mms.pdu_alt.PduHeaders;
@@ -51,6 +52,7 @@ import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.IncomingMediaMessage;
import org.thoughtcrime.securesms.mms.MmsException;
@@ -80,7 +82,6 @@ import java.util.Map;
import java.util.Set;
import static org.thoughtcrime.securesms.contactshare.Contact.Avatar;
-import static org.thoughtcrime.securesms.contactshare.Contact.deserialize;
public class MmsDatabase extends MessagingDatabase {
@@ -105,7 +106,8 @@ public class MmsDatabase extends MessagingDatabase {
static final String QUOTE_ATTACHMENT = "quote_attachment";
static final String QUOTE_MISSING = "quote_missing";
- static final String SHARED_CONTACTS = "shared_contacts";
+ static final String SHARED_CONTACTS = "shared_contacts";
+ static final String LINK_PREVIEWS = "previews";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " +
@@ -125,7 +127,8 @@ public class MmsDatabase extends MessagingDatabase {
EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " INTEGER DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + QUOTE_ID + " INTEGER DEFAULT 0, " +
QUOTE_AUTHOR + " TEXT, " + QUOTE_BODY + " TEXT, " + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " +
- QUOTE_MISSING + " INTEGER DEFAULT 0, " + SHARED_CONTACTS + " TEXT, " + UNIDENTIFIED + " INTEGER DEFAULT 0);";
+ QUOTE_MISSING + " INTEGER DEFAULT 0, " + SHARED_CONTACTS + " TEXT, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " +
+ LINK_PREVIEWS + " TEXT);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
@@ -145,7 +148,8 @@ public class MmsDatabase extends MessagingDatabase {
MESSAGE_SIZE, STATUS, TRANSACTION_ID,
BODY, PART_COUNT, ADDRESS, ADDRESS_DEVICE_ID,
DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID,
- EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, SHARED_CONTACTS, UNIDENTIFIED,
+ EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING,
+ SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED,
"json_group_array(json_object(" +
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " +
@@ -588,14 +592,19 @@ public class MmsDatabase extends MessagingDatabase {
String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES));
String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE));
- long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID));
- String quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR));
- String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY));
- boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1;
- List quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList();
- List contacts = getSharedContacts(cursor, associatedAttachments);
- Set contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList());
- List attachments = Stream.of(associatedAttachments).filterNot(Attachment::isQuote).filterNot(contactAttachments::contains).map(a -> (Attachment)a).toList();
+ long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID));
+ String quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR));
+ String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY));
+ boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1;
+ List quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList();
+ List contacts = getSharedContacts(cursor, associatedAttachments);
+ Set contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList());
+ List previews = getLinkPreviews(cursor, associatedAttachments);
+ Set previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet());
+ List attachments = Stream.of(associatedAttachments).filterNot(Attachment::isQuote)
+ .filterNot(contactAttachments::contains)
+ .filterNot(previewAttachments::contains)
+ .map(a -> (Attachment)a).toList();
Recipient recipient = Recipient.from(context, Address.fromSerialized(address), false);
List networkFailures = new LinkedList<>();
@@ -623,12 +632,12 @@ public class MmsDatabase extends MessagingDatabase {
}
if (body != null && (Types.isGroupQuit(outboxType) || Types.isGroupUpdate(outboxType))) {
- return new OutgoingGroupMediaMessage(recipient, body, attachments, timestamp, 0, quote, contacts);
+ return new OutgoingGroupMediaMessage(recipient, body, attachments, timestamp, 0, quote, contacts, previews);
} else if (Types.isExpirationTimerUpdate(outboxType)) {
return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn);
}
- OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, distributionType, quote, contacts, networkFailures, mismatches);
+ OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, distributionType, quote, contacts, previews, networkFailures, mismatches);
if (Types.isSecureType(outboxType)) {
return new OutgoingSecureMediaMessage(message);
@@ -663,7 +672,7 @@ public class MmsDatabase extends MessagingDatabase {
JSONArray jsonContacts = new JSONArray(serializedContacts);
for (int i = 0; i < jsonContacts.length(); i++) {
- Contact contact = deserialize(jsonContacts.getJSONObject(i).toString());
+ Contact contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString());
if (contact.getAvatar() != null && contact.getAvatar().getAttachmentId() != null) {
DatabaseAttachment attachment = attachmentIdMap.get(contact.getAvatar().getAttachmentId());
@@ -684,6 +693,43 @@ public class MmsDatabase extends MessagingDatabase {
return Collections.emptyList();
}
+ private List getLinkPreviews(@NonNull Cursor cursor, @NonNull List attachments) {
+ String serializedPreviews = cursor.getString(cursor.getColumnIndexOrThrow(LINK_PREVIEWS));
+
+ if (TextUtils.isEmpty(serializedPreviews)) {
+ return Collections.emptyList();
+ }
+
+ Map attachmentIdMap = new HashMap<>();
+ for (DatabaseAttachment attachment : attachments) {
+ attachmentIdMap.put(attachment.getAttachmentId(), attachment);
+ }
+
+ try {
+ List previews = new LinkedList<>();
+ JSONArray jsonPreviews = new JSONArray(serializedPreviews);
+
+ for (int i = 0; i < jsonPreviews.length(); i++) {
+ LinkPreview preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString());
+
+ if (preview.getAttachmentId() != null) {
+ DatabaseAttachment attachment = attachmentIdMap.get(preview.getAttachmentId());
+ if (attachment != null) {
+ previews.add(new LinkPreview(preview.getUrl(), preview.getTitle(), attachment));
+ }
+ } else {
+ previews.add(preview);
+ }
+ }
+
+ return previews;
+ } catch (JSONException | IOException e) {
+ Log.w(TAG, "Failed to parse shared contacts.", e);
+ }
+
+ return Collections.emptyList();
+ }
+
public long copyMessageInbox(long messageId) throws MmsException {
try {
OutgoingMediaMessage request = getOutgoingMessage(messageId);
@@ -724,6 +770,7 @@ public class MmsDatabase extends MessagingDatabase {
attachments,
new LinkedList<>(),
request.getSharedContacts(),
+ request.getLinkPreviews(),
contentValues,
null);
} catch (NoSuchMessageException e) {
@@ -783,7 +830,7 @@ public class MmsDatabase extends MessagingDatabase {
return Optional.absent();
}
- long messageId = insertMediaMessage(retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), contentValues, null);
+ long messageId = insertMediaMessage(retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), retrieved.getLinkPreviews(), contentValues, null);
if (!Types.isExpirationTimerUpdate(mailbox)) {
DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1);
@@ -922,7 +969,7 @@ public class MmsDatabase extends MessagingDatabase {
quoteAttachments.addAll(message.getOutgoingQuote().getAttachments());
}
- long messageId = insertMediaMessage(message.getBody(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), contentValues, insertListener);
+ long messageId = insertMediaMessage(message.getBody(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), contentValues, insertListener);
if (message.getRecipient().getAddress().isGroup()) {
List members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(message.getRecipient().getAddress().toGroupString(), false);
@@ -946,6 +993,7 @@ public class MmsDatabase extends MessagingDatabase {
@NonNull List attachments,
@NonNull List quoteAttachments,
@NonNull List sharedContacts,
+ @NonNull List linkPreviews,
@NonNull ContentValues contentValues,
@Nullable SmsDatabase.InsertListener insertListener)
throws MmsException
@@ -955,9 +1003,11 @@ public class MmsDatabase extends MessagingDatabase {
List allAttachments = new LinkedList<>();
List contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList();
+ List previewAttachments = Stream.of(linkPreviews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).toList();
allAttachments.addAll(attachments);
allAttachments.addAll(contactAttachments);
+ allAttachments.addAll(previewAttachments);
contentValues.put(BODY, body);
contentValues.put(PART_COUNT, allAttachments.size());
@@ -967,7 +1017,8 @@ public class MmsDatabase extends MessagingDatabase {
long messageId = db.insert(TABLE_NAME, null, contentValues);
Map insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments);
- String serializedContacts = getSerializedSharedContacts(messageId, insertedAttachments, sharedContacts);
+ String serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts);
+ String serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews);
if (!TextUtils.isEmpty(serializedContacts)) {
ContentValues contactValues = new ContentValues();
@@ -981,6 +1032,18 @@ public class MmsDatabase extends MessagingDatabase {
}
}
+ if (!TextUtils.isEmpty(serializedPreviews)) {
+ ContentValues contactValues = new ContentValues();
+ contactValues.put(LINK_PREVIEWS, serializedPreviews);
+
+ SQLiteDatabase database = databaseHelper.getReadableDatabase();
+ int rows = database.update(TABLE_NAME, contactValues, ID + " = ?", new String[]{ String.valueOf(messageId) });
+
+ if (rows <= 0) {
+ Log.w(TAG, "Failed to update message with link preview data.");
+ }
+ }
+
db.setTransactionSuccessful();
return messageId;
} finally {
@@ -1016,7 +1079,7 @@ public class MmsDatabase extends MessagingDatabase {
deleteThreads(singleThreadSet);
}
- private @Nullable String getSerializedSharedContacts(long mmsId, @NonNull Map insertedAttachmentIds, @NonNull List contacts) {
+ private @Nullable String getSerializedSharedContacts(@NonNull Map insertedAttachmentIds, @NonNull List contacts) {
if (contacts.isEmpty()) return null;
JSONArray sharedContactJson = new JSONArray();
@@ -1042,6 +1105,28 @@ public class MmsDatabase extends MessagingDatabase {
return sharedContactJson.toString();
}
+ private @Nullable String getSerializedLinkPreviews(@NonNull Map insertedAttachmentIds, @NonNull List previews) {
+ if (previews.isEmpty()) return null;
+
+ JSONArray linkPreviewJson = new JSONArray();
+
+ for (LinkPreview preview : previews) {
+ try {
+ AttachmentId attachmentId = null;
+
+ if (preview.getThumbnail().isPresent()) {
+ attachmentId = insertedAttachmentIds.get(preview.getThumbnail().get());
+ }
+
+ LinkPreview updatedPreview = new LinkPreview(preview.getUrl(), preview.getTitle(), attachmentId);
+ linkPreviewJson.put(new JSONObject(updatedPreview.serialize()));
+ } catch (JSONException | IOException e) {
+ Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e);
+ }
+ }
+ return linkPreviewJson.toString();
+ }
+
private boolean isDuplicate(IncomingMediaMessage message, long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?",
@@ -1223,7 +1308,7 @@ public class MmsDatabase extends MessagingDatabase {
message.getOutgoingQuote().isOriginalMissing(),
new SlideDeck(context, message.getOutgoingQuote().getAttachments())) :
null,
- message.getSharedContacts(), false);
+ message.getSharedContacts(), message.getLinkPreviews(), false);
}
}
@@ -1322,15 +1407,17 @@ public class MmsDatabase extends MessagingDatabase {
List networkFailures = getFailures(networkDocument);
List attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor);
List contacts = getSharedContacts(cursor, attachments);
- Set contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList());
- SlideDeck slideDeck = getSlideDeck(Stream.of(attachments).filterNot(contactAttachments::contains).toList());
+ Set contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).collect(Collectors.toSet());
+ List previews = getLinkPreviews(cursor, attachments);
+ Set previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet());
+ SlideDeck slideDeck = getSlideDeck(Stream.of(attachments).filterNot(contactAttachments::contains).filterNot(previewAttachments::contains).toList());
Quote quote = getQuote(cursor);
return new MediaMmsMessageRecord(context, id, recipient, recipient,
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount,
threadId, body, slideDeck, partCount, box, mismatches,
networkFailures, subscriptionId, expiresIn, expireStarted,
- readReceiptCount, quote, contacts, unidentified);
+ readReceiptCount, quote, contacts, previews, unidentified);
}
private Recipient getRecipientFor(String serialized) {
diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
index f230bb77f2..d1881977a9 100644
--- a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
@@ -20,7 +20,6 @@ import android.content.Context;
import android.database.Cursor;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
-import org.thoughtcrime.securesms.logging.Log;
import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteQueryBuilder;
@@ -70,7 +69,8 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT,
- MmsDatabase.SHARED_CONTACTS};
+ MmsDatabase.SHARED_CONTACTS,
+ MmsDatabase.LINK_PREVIEWS};
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
@@ -246,7 +246,8 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT,
- MmsDatabase.SHARED_CONTACTS};
+ MmsDatabase.SHARED_CONTACTS,
+ MmsDatabase.LINK_PREVIEWS};
String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
@@ -271,7 +272,8 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT,
- MmsDatabase.SHARED_CONTACTS};
+ MmsDatabase.SHARED_CONTACTS,
+ MmsDatabase.LINK_PREVIEWS};
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
@@ -338,6 +340,7 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING);
mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT);
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
+ mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
Set smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID);
diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
index 765b1fbea4..ed72faac2f 100644
--- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
+++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
@@ -59,8 +59,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int SECRET_SENDER = 13;
private static final int ATTACHMENT_CAPTIONS = 14;
private static final int ATTACHMENT_CAPTIONS_FIX = 15;
+ private static final int PREVIEWS = 16;
- private static final int DATABASE_VERSION = 15;
+ private static final int DATABASE_VERSION = 16;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@@ -308,6 +309,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
}
}
+ if (oldVersion < PREVIEWS) {
+ db.execSQL("ALTER TABLE mms ADD COLUMN previews TEXT");
+ }
+
db.setTransactionSuccessful();
} finally {
db.endTransaction();
diff --git a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
index 9ed8e54fee..6fe7183e5e 100644
--- a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
+++ b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -56,11 +57,12 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
List failures, int subscriptionId,
long expiresIn, long expireStarted, int readReceiptCount,
@Nullable Quote quote, @Nullable List contacts,
- boolean unidentified)
+ @Nullable List linkPreviews, boolean unidentified)
{
super(context, id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
- subscriptionId, expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts, unidentified);
+ subscriptionId, expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts,
+ linkPreviews, unidentified);
this.context = context.getApplicationContext();
this.partCount = partCount;
diff --git a/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java
index c33af25ca0..e8b1e5b18e 100644
--- a/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java
+++ b/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java
@@ -8,6 +8,7 @@ import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -17,9 +18,10 @@ import java.util.List;
public abstract class MmsMessageRecord extends MessageRecord {
- private final @NonNull SlideDeck slideDeck;
- private final @Nullable Quote quote;
- private final @NonNull List contacts = new LinkedList<>();
+ private final @NonNull SlideDeck slideDeck;
+ private final @Nullable Quote quote;
+ private final @NonNull List contacts = new LinkedList<>();
+ private final @NonNull List linkPreviews = new LinkedList<>();
MmsMessageRecord(Context context, long id, String body, Recipient conversationRecipient,
Recipient individualRecipient, int recipientDeviceId, long dateSent,
@@ -27,7 +29,8 @@ public abstract class MmsMessageRecord extends MessageRecord {
long type, List mismatches,
List networkFailures, int subscriptionId, long expiresIn,
long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount,
- @Nullable Quote quote, @NonNull List contacts, boolean unidentified)
+ @Nullable Quote quote, @NonNull List contacts,
+ @NonNull List linkPreviews, boolean unidentified)
{
super(context, id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified);
@@ -35,6 +38,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
this.quote = quote;
this.contacts.addAll(contacts);
+ this.linkPreviews.addAll(linkPreviews);
}
@Override
@@ -69,4 +73,8 @@ public abstract class MmsMessageRecord extends MessageRecord {
public @NonNull List getSharedContacts() {
return contacts;
}
+
+ public @NonNull List getLinkPreviews() {
+ return linkPreviews;
+ }
}
diff --git a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java
index 3a0b20e530..f0ebdd3d8e 100644
--- a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java
+++ b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java
@@ -56,7 +56,7 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
super(context, id, "", conversationRecipient, individualRecipient, recipientDeviceId,
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
new LinkedList(), new LinkedList(), subscriptionId,
- 0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), false);
+ 0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false);
this.contentLocation = contentLocation;
this.messageSize = messageSize;
diff --git a/src/org/thoughtcrime/securesms/giph/model/ChunkedImageUrl.java b/src/org/thoughtcrime/securesms/giph/model/ChunkedImageUrl.java
new file mode 100644
index 0000000000..824bf451a8
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/giph/model/ChunkedImageUrl.java
@@ -0,0 +1,56 @@
+package org.thoughtcrime.securesms.giph.model;
+
+
+import android.support.annotation.NonNull;
+
+import com.bumptech.glide.load.Key;
+
+import org.thoughtcrime.securesms.util.Conversions;
+
+import java.security.MessageDigest;
+
+public class ChunkedImageUrl implements Key {
+
+ public static final long SIZE_UNKNOWN = -1;
+
+ private final String url;
+ private final long size;
+
+ public ChunkedImageUrl(@NonNull String url) {
+ this(url, SIZE_UNKNOWN);
+ }
+
+ public ChunkedImageUrl(@NonNull String url, long size) {
+ this.url = url;
+ this.size = size;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public long getSize() {
+ return size;
+ }
+
+ @Override
+ public void updateDiskCacheKey(MessageDigest messageDigest) {
+ messageDigest.update(url.getBytes());
+ messageDigest.update(Conversions.longToByteArray(size));
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == null || !(other instanceof ChunkedImageUrl)) return false;
+
+ ChunkedImageUrl that = (ChunkedImageUrl)other;
+
+ return this.url.equals(that.url) && this.size == that.size;
+ }
+
+ @Override
+ public int hashCode() {
+ return url.hashCode() ^ (int)size;
+ }
+
+}
diff --git a/src/org/thoughtcrime/securesms/giph/model/GiphyPaddedUrl.java b/src/org/thoughtcrime/securesms/giph/model/GiphyPaddedUrl.java
deleted file mode 100644
index c00268446d..0000000000
--- a/src/org/thoughtcrime/securesms/giph/model/GiphyPaddedUrl.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package org.thoughtcrime.securesms.giph.model;
-
-
-import android.support.annotation.NonNull;
-
-import com.bumptech.glide.load.Key;
-
-import org.thoughtcrime.securesms.util.Conversions;
-
-import java.security.MessageDigest;
-
-public class GiphyPaddedUrl implements Key {
-
- private final String target;
- private final long size;
-
- public GiphyPaddedUrl(@NonNull String target, long size) {
- this.target = target;
- this.size = size;
- }
-
- public String getTarget() {
- return target;
- }
-
- public long getSize() {
- return size;
- }
-
- @Override
- public void updateDiskCacheKey(MessageDigest messageDigest) {
- messageDigest.update(target.getBytes());
- messageDigest.update(Conversions.longToByteArray(size));
- }
-
- @Override
- public boolean equals(Object other) {
- if (other == null || !(other instanceof GiphyPaddedUrl)) return false;
-
- GiphyPaddedUrl that = (GiphyPaddedUrl)other;
-
- return this.target.equals(that.target) && this.size == that.size;
- }
-
- @Override
- public int hashCode() {
- return target.hashCode() ^ (int)size;
- }
-
-}
diff --git a/src/org/thoughtcrime/securesms/giph/net/GiphyLoader.java b/src/org/thoughtcrime/securesms/giph/net/GiphyLoader.java
index d116e159e2..d1169990c3 100644
--- a/src/org/thoughtcrime/securesms/giph/net/GiphyLoader.java
+++ b/src/org/thoughtcrime/securesms/giph/net/GiphyLoader.java
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.giph.model.GiphyImage;
import org.thoughtcrime.securesms.giph.model.GiphyResponse;
+import org.thoughtcrime.securesms.net.ContentProxySelector;
import org.thoughtcrime.securesms.util.AsyncLoader;
import org.thoughtcrime.securesms.util.JsonUtils;
@@ -35,7 +36,7 @@ public abstract class GiphyLoader extends AsyncLoader> {
protected GiphyLoader(@NonNull Context context, @Nullable String searchString) {
super(context);
this.searchString = searchString;
- this.client = new OkHttpClient.Builder().proxySelector(new GiphyProxySelector()).build();
+ this.client = new OkHttpClient.Builder().proxySelector(new ContentProxySelector()).build();
}
@Override
diff --git a/src/org/thoughtcrime/securesms/giph/net/GiphyProxySelector.java b/src/org/thoughtcrime/securesms/giph/net/GiphyProxySelector.java
deleted file mode 100644
index 24c8ca47be..0000000000
--- a/src/org/thoughtcrime/securesms/giph/net/GiphyProxySelector.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package org.thoughtcrime.securesms.giph.net;
-
-
-import android.os.AsyncTask;
-import org.thoughtcrime.securesms.logging.Log;
-
-import org.thoughtcrime.securesms.BuildConfig;
-import org.thoughtcrime.securesms.util.Util;
-
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.Proxy;
-import java.net.ProxySelector;
-import java.net.SocketAddress;
-import java.net.URI;
-import java.util.ArrayList;
-import java.util.List;
-
-public class GiphyProxySelector extends ProxySelector {
-
- private static final String TAG = GiphyProxySelector.class.getSimpleName();
-
- private final List EMPTY = new ArrayList<>(1);
- private volatile List GIPHY = null;
-
- public GiphyProxySelector() {
- EMPTY.add(Proxy.NO_PROXY);
-
- if (Util.isMainThread()) {
- new AsyncTask() {
- @Override
- protected Void doInBackground(Void... params) {
- synchronized (GiphyProxySelector.this) {
- initializeGiphyProxy();
- GiphyProxySelector.this.notifyAll();
- }
- return null;
- }
- }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- } else {
- initializeGiphyProxy();
- }
- }
-
- @Override
- public List select(URI uri) {
- if (uri.getHost().endsWith("giphy.com")) return getOrCreateGiphyProxy();
- else return EMPTY;
- }
-
- @Override
- public void connectFailed(URI uri, SocketAddress address, IOException failure) {
- Log.w(TAG, failure);
- }
-
- private void initializeGiphyProxy() {
- GIPHY = new ArrayList(1) {{
- add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(BuildConfig.GIPHY_PROXY_HOST,
- BuildConfig.GIPHY_PROXY_PORT)));
- }};
- }
-
- private List getOrCreateGiphyProxy() {
- if (GIPHY == null) {
- synchronized (this) {
- while (GIPHY == null) Util.wait(this, 0);
- }
- }
-
- return GIPHY;
- }
-
-}
diff --git a/src/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java b/src/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java
index 602f8fdb66..0a3851877c 100644
--- a/src/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java
+++ b/src/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java
@@ -17,6 +17,7 @@ import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.engine.GlideException;
+import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import com.bumptech.glide.load.resource.gif.GifDrawable;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
@@ -25,7 +26,7 @@ import com.bumptech.glide.util.ByteBufferUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.giph.model.GiphyImage;
-import org.thoughtcrime.securesms.giph.model.GiphyPaddedUrl;
+import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.Util;
@@ -70,7 +71,7 @@ class GiphyAdapter extends RecyclerView.Adapter {
Log.w(TAG, e);
synchronized (this) {
- if (new GiphyPaddedUrl(image.getGifUrl(), image.getGifSize()).equals(model)) {
+ if (new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()).equals(model)) {
this.modelReady = true;
notifyAll();
}
@@ -82,7 +83,7 @@ class GiphyAdapter extends RecyclerView.Adapter {
@Override
public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) {
synchronized (this) {
- if (new GiphyPaddedUrl(image.getGifUrl(), image.getGifSize()).equals(model)) {
+ if (new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()).equals(model)) {
this.modelReady = true;
notifyAll();
}
@@ -100,8 +101,8 @@ class GiphyAdapter extends RecyclerView.Adapter {
}
GifDrawable drawable = glideRequests.asGif()
- .load(forMms ? new GiphyPaddedUrl(image.getGifMmsUrl(), image.getMmsGifSize()) :
- new GiphyPaddedUrl(image.getGifUrl(), image.getGifSize()))
+ .load(forMms ? new ChunkedImageUrl(image.getGifMmsUrl(), image.getMmsGifSize()) :
+ new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()))
.submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
.get();
@@ -148,22 +149,24 @@ class GiphyAdapter extends RecyclerView.Adapter {
holder.gifProgress.setVisibility(View.GONE);
RequestBuilder thumbnailRequest = GlideApp.with(context)
- .load(new GiphyPaddedUrl(image.getStillUrl(), image.getStillSize()))
+ .load(new ChunkedImageUrl(image.getStillUrl(), image.getStillSize()))
.diskCacheStrategy(DiskCacheStrategy.ALL);
if (Util.isLowMemory(context)) {
- glideRequests.load(new GiphyPaddedUrl(image.getStillUrl(), image.getStillSize()))
+ glideRequests.load(new ChunkedImageUrl(image.getStillUrl(), image.getStillSize()))
.placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context)))
.diskCacheStrategy(DiskCacheStrategy.ALL)
+ .transition(DrawableTransitionOptions.withCrossFade())
.listener(holder)
.into(holder.thumbnail);
holder.setModelReady();
} else {
- glideRequests.load(new GiphyPaddedUrl(image.getGifUrl(), image.getGifSize()))
+ glideRequests.load(new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()))
.thumbnail(thumbnailRequest)
.placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context)))
.diskCacheStrategy(DiskCacheStrategy.ALL)
+ .transition(DrawableTransitionOptions.withCrossFade())
.listener(holder)
.into(holder.thumbnail);
}
diff --git a/src/org/thoughtcrime/securesms/glide/ChunkedImageUrlFetcher.java b/src/org/thoughtcrime/securesms/glide/ChunkedImageUrlFetcher.java
new file mode 100644
index 0000000000..b0b7adc4a6
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/glide/ChunkedImageUrlFetcher.java
@@ -0,0 +1,75 @@
+package org.thoughtcrime.securesms.glide;
+
+
+import android.support.annotation.NonNull;
+
+import com.bumptech.glide.Priority;
+import com.bumptech.glide.load.DataSource;
+import com.bumptech.glide.load.data.DataFetcher;
+
+import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.net.ChunkedDataFetcher;
+import org.thoughtcrime.securesms.net.RequestController;
+
+import java.io.InputStream;
+
+import okhttp3.OkHttpClient;
+
+class ChunkedImageUrlFetcher implements DataFetcher {
+
+ private static final String TAG = ChunkedImageUrlFetcher.class.getSimpleName();
+
+ private final OkHttpClient client;
+ private final ChunkedImageUrl url;
+
+ private RequestController requestController;
+
+ ChunkedImageUrlFetcher(@NonNull OkHttpClient client, @NonNull ChunkedImageUrl url) {
+ this.client = client;
+ this.url = url;
+ }
+
+ @Override
+ public void loadData(@NonNull Priority priority, @NonNull DataCallback super InputStream> callback) {
+ ChunkedDataFetcher fetcher = new ChunkedDataFetcher(client);
+ requestController = fetcher.fetch(url.getUrl(), url.getSize(), new ChunkedDataFetcher.Callback() {
+ @Override
+ public void onSuccess(InputStream stream) {
+ callback.onDataReady(stream);
+ }
+
+ @Override
+ public void onFailure(Exception e) {
+ callback.onLoadFailed(e);
+ }
+ });
+ }
+
+ @Override
+ public void cleanup() {
+ if (requestController != null) {
+ requestController.cancel();
+ }
+ }
+
+ @Override
+ public void cancel() {
+ Log.d(TAG, "Canceled.");
+ if (requestController != null) {
+ requestController.cancel();
+ }
+ }
+
+ @NonNull
+ @Override
+ public Class getDataClass() {
+ return InputStream.class;
+ }
+
+ @NonNull
+ @Override
+ public DataSource getDataSource() {
+ return DataSource.REMOTE;
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/glide/ChunkedImageUrlLoader.java b/src/org/thoughtcrime/securesms/glide/ChunkedImageUrlLoader.java
new file mode 100644
index 0000000000..afd4798f80
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/glide/ChunkedImageUrlLoader.java
@@ -0,0 +1,55 @@
+package org.thoughtcrime.securesms.glide;
+
+import android.support.annotation.Nullable;
+
+import com.bumptech.glide.load.Options;
+import com.bumptech.glide.load.model.ModelLoader;
+import com.bumptech.glide.load.model.ModelLoaderFactory;
+import com.bumptech.glide.load.model.MultiModelLoaderFactory;
+
+import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl;
+import org.thoughtcrime.securesms.net.ContentProxySelector;
+
+import java.io.InputStream;
+
+import okhttp3.OkHttpClient;
+
+public class ChunkedImageUrlLoader implements ModelLoader {
+
+ private final OkHttpClient client;
+
+ private ChunkedImageUrlLoader(OkHttpClient client) {
+ this.client = client;
+ }
+
+ @Nullable
+ @Override
+ public LoadData buildLoadData(ChunkedImageUrl url, int width, int height, Options options) {
+ return new LoadData<>(url, new ChunkedImageUrlFetcher(client, url));
+ }
+
+ @Override
+ public boolean handles(ChunkedImageUrl url) {
+ return true;
+ }
+
+ public static class Factory implements ModelLoaderFactory {
+
+ private final OkHttpClient client;
+
+ public Factory() {
+ this.client = new OkHttpClient.Builder()
+ .proxySelector(new ContentProxySelector())
+ .cache(null)
+ .build();
+ }
+
+ @Override
+ public ModelLoader build(MultiModelLoaderFactory multiFactory) {
+ return new ChunkedImageUrlLoader(client);
+ }
+
+ @Override
+ public void teardown() {}
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/glide/GiphyPaddedUrlFetcher.java b/src/org/thoughtcrime/securesms/glide/GiphyPaddedUrlFetcher.java
deleted file mode 100644
index 7112dfce3f..0000000000
--- a/src/org/thoughtcrime/securesms/glide/GiphyPaddedUrlFetcher.java
+++ /dev/null
@@ -1,285 +0,0 @@
-package org.thoughtcrime.securesms.glide;
-
-
-import android.support.annotation.NonNull;
-
-import com.bumptech.glide.Priority;
-import com.bumptech.glide.load.DataSource;
-import com.bumptech.glide.load.data.DataFetcher;
-import com.bumptech.glide.util.ContentLengthInputStream;
-
-import org.thoughtcrime.securesms.giph.model.GiphyPaddedUrl;
-import org.thoughtcrime.securesms.logging.Log;
-import org.thoughtcrime.securesms.util.Util;
-
-import java.io.FilterInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.LinkedList;
-import java.util.List;
-
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.Response;
-import okhttp3.ResponseBody;
-
-class GiphyPaddedUrlFetcher implements DataFetcher {
-
- private static final String TAG = GiphyPaddedUrlFetcher.class.getSimpleName();
-
- private static final long MB = 1024 * 1024;
- private static final long KB = 1024;
-
- private final OkHttpClient client;
- private final GiphyPaddedUrl url;
-
- private List bodies;
- private List rangeStreams;
- private InputStream stream;
-
- GiphyPaddedUrlFetcher(@NonNull OkHttpClient client,
- @NonNull GiphyPaddedUrl url)
- {
- this.client = client;
- this.url = url;
- }
-
- @Override
- public void loadData(@NonNull Priority priority, @NonNull DataCallback super InputStream> callback) {
- bodies = new LinkedList<>();
- rangeStreams = new LinkedList<>();
- stream = null;
-
- try {
- List requestPattern = getRequestPattern(url.getSize());
-
- for (ByteRange range : requestPattern) {
- Request request = new Request.Builder()
- .addHeader("Range", "bytes=" + range.start + "-" + range.end)
- .addHeader("Accept-Encoding", "identity")
- .url(url.getTarget())
- .get()
- .build();
-
- Response response = client.newCall(request).execute();
-
- if (!response.isSuccessful()) {
- throw new IOException("Bad response: " + response.code() + " - " + response.message());
- }
-
- ResponseBody responseBody = response.body();
-
- if (responseBody == null) throw new IOException("Response body was null");
- else bodies.add(responseBody);
-
- rangeStreams.add(new SkippingInputStream(ContentLengthInputStream.obtain(responseBody.byteStream(), responseBody.contentLength()), range.ignoreFirst));
- }
-
- stream = new InputStreamList(rangeStreams);
- callback.onDataReady(stream);
- } catch (IOException e) {
- Log.w(TAG, e);
- callback.onLoadFailed(e);
- }
- }
-
- @Override
- public void cleanup() {
- if (rangeStreams != null) {
- for (InputStream rangeStream : rangeStreams) {
- try {
- if (rangeStream != null) rangeStream.close();
- } catch (IOException ignored) {}
- }
- }
-
- if (bodies != null) {
- for (ResponseBody body : bodies) {
- if (body != null) body.close();
- }
- }
-
- if (stream != null) {
- try {
- stream.close();
- } catch (IOException ignored) {}
- }
- }
-
- @Override
- public void cancel() {
-
- }
-
- @NonNull
- @Override
- public Class getDataClass() {
- return InputStream.class;
- }
-
- @NonNull
- @Override
- public DataSource getDataSource() {
- return DataSource.REMOTE;
- }
-
- private List getRequestPattern(long size) throws IOException {
- if (size > MB) return getRequestPattern(size, MB);
- else if (size > 500 * KB) return getRequestPattern(size, 500 * KB);
- else if (size > 100 * KB) return getRequestPattern(size, 100 * KB);
- else if (size > 50 * KB) return getRequestPattern(size, 50 * KB);
- else if (size > KB) return getRequestPattern(size, KB);
-
- throw new IOException("Unsupported size: " + size);
- }
-
- private List getRequestPattern(long size, long increment) {
- List results = new LinkedList<>();
-
- long offset = 0;
-
- while (size - offset > increment) {
- results.add(new ByteRange(offset, offset + increment - 1, 0));
- offset += increment;
- }
-
- if (size - offset > 0) {
- results.add(new ByteRange(size - increment, size-1, increment - (size - offset)));
- }
-
- return results;
- }
-
- private static class ByteRange {
- private final long start;
- private final long end;
- private final long ignoreFirst;
-
- private ByteRange(long start, long end, long ignoreFirst) {
- this.start = start;
- this.end = end;
- this.ignoreFirst = ignoreFirst;
- }
- }
-
- private static class SkippingInputStream extends FilterInputStream {
-
- private long skip;
-
- SkippingInputStream(InputStream in, long skip) {
- super(in);
- this.skip = skip;
- }
-
- @Override
- public int read() throws IOException {
- if (skip != 0) {
- skipFully(skip);
- skip = 0;
- }
-
- return super.read();
- }
-
- @Override
- public int read(@NonNull byte[] buffer) throws IOException {
- if (skip != 0) {
- skipFully(skip);
- skip = 0;
- }
-
- return super.read(buffer);
- }
-
- @Override
- public int read(@NonNull byte[] buffer, int offset, int length) throws IOException {
- if (skip != 0) {
- skipFully(skip);
- skip = 0;
- }
-
- return super.read(buffer, offset, length);
- }
-
- @Override
- public int available() throws IOException {
- return Util.toIntExact(super.available() - skip);
- }
-
- private void skipFully(long amount) throws IOException {
- byte[] buffer = new byte[4096];
-
- while (amount > 0) {
- int read = super.read(buffer, 0, Math.min(buffer.length, Util.toIntExact(amount)));
-
- if (read != -1) amount -= read;
- else return;
- }
- }
- }
-
- private static class InputStreamList extends InputStream {
-
- private final List inputStreams;
-
- private int currentStreamIndex = 0;
-
- InputStreamList(List inputStreams) {
- this.inputStreams = inputStreams;
- }
-
- @Override
- public int read() throws IOException {
- while (currentStreamIndex < inputStreams.size()) {
- int result = inputStreams.get(currentStreamIndex).read();
-
- if (result == -1) currentStreamIndex++;
- else return result;
- }
-
- return -1;
- }
-
- @Override
- public int read(@NonNull byte[] buffer, int offset, int length) throws IOException {
- while (currentStreamIndex < inputStreams.size()) {
- int result = inputStreams.get(currentStreamIndex).read(buffer, offset, length);
-
- if (result == -1) currentStreamIndex++;
- else return result;
- }
-
- return -1;
- }
-
- @Override
- public int read(@NonNull byte[] buffer) throws IOException {
- return read(buffer, 0, buffer.length);
- }
-
- @Override
- public void close() throws IOException {
- for (InputStream stream : inputStreams) {
- try {
- stream.close();
- } catch (IOException ignored) {}
- }
- }
-
- @Override
- public int available() {
- int total = 0;
-
- for (int i=currentStreamIndex;i {
-
- private final OkHttpClient client;
-
- private GiphyPaddedUrlLoader(OkHttpClient client) {
- this.client = client;
- }
-
- @Nullable
- @Override
- public LoadData buildLoadData(GiphyPaddedUrl url, int width, int height, Options options) {
- return new LoadData<>(url, new GiphyPaddedUrlFetcher(client, url));
- }
-
- @Override
- public boolean handles(GiphyPaddedUrl url) {
- return true;
- }
-
- public static class Factory implements ModelLoaderFactory {
-
- private final OkHttpClient client;
-
- public Factory() {
- this.client = new OkHttpClient.Builder().proxySelector(new GiphyProxySelector()).build();
- }
-
- @Override
- public ModelLoader build(MultiModelLoaderFactory multiFactory) {
- return new GiphyPaddedUrlLoader(client);
- }
-
- @Override
- public void teardown() {}
- }
-}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java b/src/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java
index a2aecc0fa5..a6f8fa9629 100644
--- a/src/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java
+++ b/src/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java
@@ -8,7 +8,7 @@ import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
-import org.thoughtcrime.securesms.giph.net.GiphyProxySelector;
+import org.thoughtcrime.securesms.net.ContentProxySelector;
import java.io.InputStream;
@@ -45,7 +45,7 @@ public class OkHttpUrlLoader implements ModelLoader {
synchronized (Factory.class) {
if (internalClient == null) {
internalClient = new OkHttpClient.Builder()
- .proxySelector(new GiphyProxySelector())
+ .proxySelector(new ContentProxySelector())
.build();
}
}
diff --git a/src/org/thoughtcrime/securesms/groups/GroupManager.java b/src/org/thoughtcrime/securesms/groups/GroupManager.java
index 355c3996a1..efe8f96cb6 100644
--- a/src/org/thoughtcrime/securesms/groups/GroupManager.java
+++ b/src/org/thoughtcrime/securesms/groups/GroupManager.java
@@ -115,7 +115,7 @@ public class GroupManager {
avatarAttachment = new UriAttachment(avatarUri, MediaUtil.IMAGE_PNG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, avatar.length, null, false, false, null);
}
- OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, null, Collections.emptyList());
+ OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, null, Collections.emptyList(), Collections.emptyList());
long threadId = MessageSender.send(context, outgoingMessage, -1, false, null);
return new GroupActionResult(groupRecipient, threadId);
diff --git a/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java b/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java
index e9e35d54dd..8d450a8f4a 100644
--- a/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java
+++ b/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java
@@ -212,7 +212,7 @@ public class GroupMessageProcessor {
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
Address addres = Address.fromExternal(context, GroupUtil.getEncodedId(group.getGroupId(), false));
Recipient recipient = Recipient.from(context, addres, false);
- OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(recipient, storage, null, content.getTimestamp(), 0, null, Collections.emptyList());
+ OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(recipient, storage, null, content.getTimestamp(), 0, null, Collections.emptyList(), Collections.emptyList());
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);
diff --git a/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java b/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java
index dc6c755420..2e758f34bd 100644
--- a/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java
@@ -101,7 +101,7 @@ public class AttachmentUploadJob extends ContextJob implements InjectableType {
exception instanceof ConnectException;
}
- protected SignalServiceAttachment getAttachmentFor(Attachment attachment) {
+ private SignalServiceAttachment getAttachmentFor(Attachment attachment) {
try {
if (attachment.getDataUri() == null || attachment.getSize() == 0) throw new IOException("Assertion failed, outgoing attachment has no data!");
InputStream is = PartAuthority.getAttachmentStream(context, attachment.getDataUri());
diff --git a/src/org/thoughtcrime/securesms/jobs/MultiDeviceConfigurationUpdateJob.java b/src/org/thoughtcrime/securesms/jobs/MultiDeviceConfigurationUpdateJob.java
index 26778f47a5..fe09ad61ff 100644
--- a/src/org/thoughtcrime/securesms/jobs/MultiDeviceConfigurationUpdateJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/MultiDeviceConfigurationUpdateJob.java
@@ -33,18 +33,25 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
private static final String KEY_READ_RECEIPTS_ENABLED = "read_receipts_enabled";
private static final String KEY_TYPING_INDICATORS_ENABLED = "typing_indicators_enabled";
private static final String KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED = "unidentified_delivery_indicators_enabled";
+ private static final String KEY_LINK_PREVIEWS_ENABLED = "link_previews_enabled";
@Inject transient SignalServiceMessageSender messageSender;
private boolean readReceiptsEnabled;
private boolean typingIndicatorsEnabled;
private boolean unidentifiedDeliveryIndicatorsEnabled;
+ private boolean linkPreviewsEnabled;
public MultiDeviceConfigurationUpdateJob(@NonNull Context context, @NonNull WorkerParameters workerParameters) {
super(context, workerParameters);
}
- public MultiDeviceConfigurationUpdateJob(Context context, boolean readReceiptsEnabled, boolean typingIndicatorsEnabled, boolean unidentifiedDeliveryIndicatorsEnabled) {
+ public MultiDeviceConfigurationUpdateJob(Context context,
+ boolean readReceiptsEnabled,
+ boolean typingIndicatorsEnabled,
+ boolean unidentifiedDeliveryIndicatorsEnabled,
+ boolean linkPreviewsEnabled)
+ {
super(context, JobParameters.newBuilder()
.withGroupId("__MULTI_DEVICE_CONFIGURATION_UPDATE_JOB__")
.withNetworkRequirement()
@@ -53,6 +60,7 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
this.readReceiptsEnabled = readReceiptsEnabled;
this.typingIndicatorsEnabled = typingIndicatorsEnabled;
this.unidentifiedDeliveryIndicatorsEnabled = unidentifiedDeliveryIndicatorsEnabled;
+ this.linkPreviewsEnabled = linkPreviewsEnabled;
}
@Override
@@ -60,6 +68,7 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
readReceiptsEnabled = data.getBoolean(KEY_READ_RECEIPTS_ENABLED);
typingIndicatorsEnabled = data.getBoolean(KEY_TYPING_INDICATORS_ENABLED);
unidentifiedDeliveryIndicatorsEnabled = data.getBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED);
+ linkPreviewsEnabled = data.getBoolean(KEY_LINK_PREVIEWS_ENABLED);
}
@Override
@@ -67,6 +76,7 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
return dataBuilder.putBoolean(KEY_READ_RECEIPTS_ENABLED, readReceiptsEnabled)
.putBoolean(KEY_TYPING_INDICATORS_ENABLED, typingIndicatorsEnabled)
.putBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED, unidentifiedDeliveryIndicatorsEnabled)
+ .putBoolean(KEY_LINK_PREVIEWS_ENABLED, linkPreviewsEnabled)
.build();
}
@@ -79,7 +89,8 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
messageSender.sendMessage(SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.of(readReceiptsEnabled),
Optional.of(unidentifiedDeliveryIndicatorsEnabled),
- Optional.of(typingIndicatorsEnabled))),
+ Optional.of(typingIndicatorsEnabled),
+ Optional.of(linkPreviewsEnabled))),
UnidentifiedAccessUtil.getAccessForSync(context));
}
diff --git a/src/org/thoughtcrime/securesms/jobs/MultiDeviceReadReceiptUpdateJob.java b/src/org/thoughtcrime/securesms/jobs/MultiDeviceReadReceiptUpdateJob.java
index c08250f8e2..dac32d10f2 100644
--- a/src/org/thoughtcrime/securesms/jobs/MultiDeviceReadReceiptUpdateJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/MultiDeviceReadReceiptUpdateJob.java
@@ -70,7 +70,7 @@ public class MultiDeviceReadReceiptUpdateJob extends ContextJob implements Injec
return;
}
- messageSender.sendMessage(SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.of(enabled), Optional.absent(), Optional.absent())),
+ messageSender.sendMessage(SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.of(enabled), Optional.absent(), Optional.absent(), Optional.absent())),
UnidentifiedAccessUtil.getAccessForSync(context));
}
diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java
index 9f95d7d8b1..42899049ab 100644
--- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java
@@ -9,8 +9,11 @@ import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
+import android.text.TextUtils;
import android.util.Pair;
+import com.annimon.stream.Stream;
+
import org.signal.libsignal.metadata.InvalidMetadataMessageException;
import org.signal.libsignal.metadata.InvalidMetadataVersionException;
import org.signal.libsignal.metadata.ProtocolDuplicateMessageException;
@@ -53,6 +56,8 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.groups.GroupMessageProcessor;
import org.thoughtcrime.securesms.jobmanager.JobParameters;
import org.thoughtcrime.securesms.jobmanager.SafeData;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
+import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.IncomingMediaMessage;
import org.thoughtcrime.securesms.mms.MmsException;
@@ -80,6 +85,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
import org.whispersystems.signalservice.api.messages.SignalServiceContent;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
+import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
@@ -227,7 +233,7 @@ public class PushDecryptJob extends ContextJob {
if (content.getDataMessage().isPresent()) {
SignalServiceDataMessage message = content.getDataMessage().get();
- boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent();
+ boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent();
if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId);
else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId);
@@ -484,6 +490,7 @@ public class PushDecryptJob extends ContextJob {
message.getGroupInfo(),
Optional.absent(),
Optional.absent(),
+ Optional.absent(),
Optional.absent());
database.insertSecureDecryptedMessageInbox(mediaMessage, -1);
@@ -518,7 +525,7 @@ public class PushDecryptJob extends ContextJob {
threadId = GroupMessageProcessor.process(context, content, message.getMessage(), true);
} else if (message.getMessage().isExpirationUpdate()) {
threadId = handleSynchronizeSentExpirationUpdate(message);
- } else if (message.getMessage().getAttachments().isPresent() || message.getMessage().getQuote().isPresent()) {
+ } else if (message.getMessage().getAttachments().isPresent() || message.getMessage().getQuote().isPresent() || message.getMessage().getPreviews().isPresent()) {
threadId = handleSynchronizeSentMediaMessage(message);
} else {
threadId = handleSynchronizeSentTextMessage(message);
@@ -581,7 +588,8 @@ public class PushDecryptJob extends ContextJob {
.add(new MultiDeviceConfigurationUpdateJob(getContext(),
TextSecurePreferences.isReadReceiptsEnabled(getContext()),
TextSecurePreferences.isTypingIndicatorsEnabled(getContext()),
- TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext())));
+ TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
+ TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
}
}
@@ -617,18 +625,20 @@ public class PushDecryptJob extends ContextJob {
notifyTypingStoppedFromIncomingMessage(getMessageDestination(content, message), content.getSender(), content.getSenderDevice());
try {
- MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
- Optional quote = getValidatedQuote(message.getQuote());
- Optional> sharedContacts = getContacts(message.getSharedContacts());
- IncomingMediaMessage mediaMessage = new IncomingMediaMessage(Address.fromExternal(context, content.getSender()),
- message.getTimestamp(), -1,
- message.getExpiresInSeconds() * 1000L, false,
- content.isNeedsReceipt(),
- message.getBody(),
- message.getGroupInfo(),
- message.getAttachments(),
- quote,
- sharedContacts);
+ MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
+ Optional quote = getValidatedQuote(message.getQuote());
+ Optional> sharedContacts = getContacts(message.getSharedContacts());
+ Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or(""));
+ IncomingMediaMessage mediaMessage = new IncomingMediaMessage(Address.fromExternal(context, content.getSender()),
+ message.getTimestamp(), -1,
+ message.getExpiresInSeconds() * 1000L, false,
+ content.isNeedsReceipt(),
+ message.getBody(),
+ message.getGroupInfo(),
+ message.getAttachments(),
+ quote,
+ sharedContacts,
+ linkPreviews);
Optional insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1);
@@ -673,17 +683,19 @@ public class PushDecryptJob extends ContextJob {
private long handleSynchronizeSentMediaMessage(@NonNull SentTranscriptMessage message)
throws MmsException
{
- MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
- Recipient recipients = getSyncMessageDestination(message);
- Optional quote = getValidatedQuote(message.getMessage().getQuote());
- Optional> sharedContacts = getContacts(message.getMessage().getSharedContacts());
- OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipients, message.getMessage().getBody().orNull(),
- PointerAttachment.forPointers(message.getMessage().getAttachments()),
- message.getTimestamp(), -1,
- message.getMessage().getExpiresInSeconds() * 1000,
- ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(),
- sharedContacts.or(Collections.emptyList()),
- Collections.emptyList(), Collections.emptyList());
+ MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
+ Recipient recipients = getSyncMessageDestination(message);
+ Optional quote = getValidatedQuote(message.getMessage().getQuote());
+ Optional> sharedContacts = getContacts(message.getMessage().getSharedContacts());
+ Optional> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or(""));
+ OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipients, message.getMessage().getBody().orNull(),
+ PointerAttachment.forPointers(message.getMessage().getAttachments()),
+ message.getTimestamp(), -1,
+ message.getMessage().getExpiresInSeconds() * 1000,
+ ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(),
+ sharedContacts.or(Collections.emptyList()),
+ previews.or(Collections.emptyList()),
+ Collections.emptyList(), Collections.emptyList());
mediaMessage = new OutgoingSecureMediaMessage(mediaMessage);
@@ -784,7 +796,7 @@ public class PushDecryptJob extends ContextJob {
long messageId;
if (isGroup) {
- OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, new SlideDeck(), body, message.getTimestamp(), -1, expiresInMillis, ThreadDatabase.DistributionTypes.DEFAULT, null, Collections.emptyList());
+ OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, new SlideDeck(), body, message.getTimestamp(), -1, expiresInMillis, ThreadDatabase.DistributionTypes.DEFAULT, null, Collections.emptyList(), Collections.emptyList());
outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage);
messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, null);
@@ -1003,7 +1015,14 @@ public class PushDecryptJob extends ContextJob {
List attachments = new LinkedList<>();
if (message.isMms()) {
- attachments = ((MmsMessageRecord) message).getSlideDeck().asAttachments();
+ MmsMessageRecord mmsMessage = (MmsMessageRecord) message;
+ attachments = mmsMessage.getSlideDeck().asAttachments();
+ if (attachments.isEmpty()) {
+ attachments.addAll(Stream.of(mmsMessage.getLinkPreviews())
+ .filter(lp -> lp.getThumbnail().isPresent())
+ .map(lp -> lp.getThumbnail().get())
+ .toList());
+ }
}
return Optional.of(new QuoteModel(quote.get().getId(), author, message.getBody(), false, attachments));
@@ -1029,6 +1048,30 @@ public class PushDecryptJob extends ContextJob {
return Optional.of(contacts);
}
+ private Optional> getLinkPreviews(Optional> previews, @NonNull String message) {
+ if (!previews.isPresent()) return Optional.absent();
+
+ List linkPreviews = new ArrayList<>(previews.get().size());
+
+ for (Preview preview : previews.get()) {
+ Optional thumbnail = PointerAttachment.forPointer(preview.getImage());
+ Optional url = Optional.fromNullable(preview.getUrl());
+ Optional title = Optional.fromNullable(preview.getTitle());
+ boolean hasContent = !TextUtils.isEmpty(title.or("")) || thumbnail.isPresent();
+ boolean presentInBody = url.isPresent() && LinkPreviewUtil.findWhitelistedUrls(message).contains(url.get());
+ boolean validDomain = url.isPresent() && LinkPreviewUtil.isWhitelistedLinkUrl(url.get());
+
+ if (hasContent && presentInBody && validDomain) {
+ LinkPreview linkPreview = new LinkPreview(url.get(), title.or(""), thumbnail);
+ linkPreviews.add(linkPreview);
+ } else {
+ Log.w(TAG, String.format("Discarding an invalid link preview. hasContent: %b presentInBody: %b validDomain: %b", hasContent, presentInBody, validDomain));
+ }
+ }
+
+ return Optional.of(linkPreviews);
+ }
+
private Optional insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp) {
SmsDatabase database = DatabaseFactory.getSmsDatabase(context);
IncomingTextMessage textMessage = new IncomingTextMessage(Address.fromExternal(context, sender),
diff --git a/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
index 059474ba45..ba24f4467d 100644
--- a/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
@@ -41,6 +41,7 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
+import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Quote;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
@@ -50,6 +51,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupC
import java.io.IOException;
import java.util.Collections;
+import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -93,16 +95,15 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
@WorkerThread
public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Address destination, @Nullable Address filterAddress) {
try {
- MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
- OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
+ MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
+ OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
+ List attachments = new LinkedList<>();
- if (message.isGroup()) {
- Log.i(TAG, "Group update message. Using legacy attachment upload path.");
- jobManager.add(new PushGroupSendJob(context, messageId, destination, filterAddress));
- return;
- }
+ attachments.addAll(message.getAttachments());
+ attachments.addAll(Stream.of(message.getLinkPreviews()).filter(p -> p.getThumbnail().isPresent()).map(p -> p.getThumbnail().get()).toList());
+ attachments.addAll(Stream.of(message.getSharedContacts()).filter(c -> c.getAvatar() != null).map(c -> c.getAvatar().getAttachment()).withoutNulls().toList());
- List attachmentJobs = Stream.of(message.getAttachments()).map(a -> new AttachmentUploadJob(context, ((DatabaseAttachment) a).getAttachmentId())).toList();
+ List attachmentJobs = Stream.of(attachments).map(a -> new AttachmentUploadJob(context, ((DatabaseAttachment) a).getAttachmentId())).toList();
ChainParameters chainParams = new ChainParameters.Builder().setGroupId(destination.serialize()).build();
if (attachmentJobs.isEmpty()) {
@@ -237,7 +238,9 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
Optional profileKey = getProfileKey(message.getRecipient());
Optional quote = getQuoteFor(message);
List sharedContacts = getSharedContactsFor(message);
+ List previews = getPreviewsFor(message);
List addresses = Stream.of(destinations).map(this::getPushAddress).toList();
+ List attachmentPointers = getAttachmentPointersFor(message.getAttachments());
List> unidentifiedAccess = Stream.of(addresses)
.map(address -> Address.fromSerialized(address.getNumber()))
@@ -246,13 +249,9 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
.toList();
if (message.isGroup()) {
- MediaConstraints mediaConstraints = MediaConstraints.getPushMediaConstraints();
- List scaledAttachments = scaleAndStripExifFromAttachments(mediaConstraints, message.getAttachments());
- List attachmentStreams = getAttachmentsFor(scaledAttachments);
-
OutgoingGroupMediaMessage groupMessage = (OutgoingGroupMediaMessage) message;
GroupContext groupContext = groupMessage.getGroupContext();
- SignalServiceAttachment avatar = attachmentStreams.isEmpty() ? null : attachmentStreams.get(0);
+ SignalServiceAttachment avatar = attachmentPointers.isEmpty() ? null : attachmentPointers.get(0);
SignalServiceGroup.Type type = groupMessage.isGroupQuit() ? SignalServiceGroup.Type.QUIT : SignalServiceGroup.Type.UPDATE;
SignalServiceGroup group = new SignalServiceGroup(type, GroupUtil.getDecodedId(groupId), groupContext.getName(), groupContext.getMembersList(), avatar);
SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder()
@@ -263,8 +262,6 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
return messageSender.sendMessage(addresses, unidentifiedAccess, groupDataMessage);
} else {
- List attachmentPointers = getAttachmentPointersFor(message.getAttachments());
-
SignalServiceGroup group = new SignalServiceGroup(GroupUtil.getDecodedId(groupId));
SignalServiceDataMessage groupMessage = SignalServiceDataMessage.newBuilder()
.withTimestamp(message.getSentTimeMillis())
@@ -276,6 +273,7 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
.withProfileKey(profileKey.orNull())
.withQuote(quote.orNull())
.withSharedContacts(sharedContacts)
+ .withPreviews(previews)
.build();
return messageSender.sendMessage(addresses, unidentifiedAccess, groupMessage);
diff --git a/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java
index e264626b0d..d01e9b9b24 100644
--- a/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java
@@ -7,9 +7,11 @@ import android.support.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.ApplicationContext;
+import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.Address;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
@@ -18,6 +20,7 @@ import org.thoughtcrime.securesms.dependencies.InjectableType;
import org.thoughtcrime.securesms.jobmanager.ChainParameters;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.SafeData;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
@@ -32,12 +35,14 @@ import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
+import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import java.io.FileNotFoundException;
import java.io.IOException;
+import java.util.LinkedList;
import java.util.List;
import javax.inject.Inject;
@@ -69,9 +74,15 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType {
@WorkerThread
public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Address destination) {
try {
- MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
- OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
- List attachmentJobs = Stream.of(message.getAttachments()).map(a -> new AttachmentUploadJob(context, ((DatabaseAttachment) a).getAttachmentId())).toList();
+ MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
+ OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
+ List attachments = new LinkedList<>();
+
+ attachments.addAll(message.getAttachments());
+ attachments.addAll(Stream.of(message.getLinkPreviews()).filter(p -> p.getThumbnail().isPresent()).map(p -> p.getThumbnail().get()).toList());
+ attachments.addAll(Stream.of(message.getSharedContacts()).filter(c -> c.getAvatar() != null).map(c -> c.getAvatar().getAttachment()).withoutNulls().toList());
+
+ List attachmentJobs = Stream.of(attachments).map(a -> new AttachmentUploadJob(context, ((DatabaseAttachment) a).getAttachmentId())).toList();
ChainParameters chainParams = new ChainParameters.Builder().setGroupId(destination.serialize()).build();
if (attachmentJobs.isEmpty()) {
@@ -191,6 +202,7 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType {
Optional profileKey = getProfileKey(message.getRecipient());
Optional quote = getQuoteFor(message);
List sharedContacts = getSharedContactsFor(message);
+ List previews = getPreviewsFor(message);
SignalServiceDataMessage mediaMessage = SignalServiceDataMessage.newBuilder()
.withBody(message.getBody())
.withAttachments(serviceAttachments)
@@ -199,6 +211,7 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType {
.withProfileKey(profileKey.orNull())
.withQuote(quote.orNull())
.withSharedContacts(sharedContacts)
+ .withPreviews(previews)
.asExpirationUpdate(message.isExpirationUpdate())
.build();
diff --git a/src/org/thoughtcrime/securesms/jobs/PushSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushSendJob.java
index 7014077bfd..8461ebfa92 100644
--- a/src/org/thoughtcrime/securesms/jobs/PushSendJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/PushSendJob.java
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobmanager.JobParameters;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
@@ -36,6 +37,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
+import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
@@ -54,7 +56,7 @@ public abstract class PushSendJob extends SendJob {
private static final String TAG = PushSendJob.class.getSimpleName();
private static final long CERTIFICATE_EXPIRATION_BUFFER = TimeUnit.DAYS.toMillis(1);
- protected PushSendJob(@NonNull Context context, @NonNull WorkerParameters workerParameters) {
+ public PushSendJob(@NonNull Context context, @NonNull WorkerParameters workerParameters) {
super(context, workerParameters);
}
@@ -247,6 +249,13 @@ public abstract class PushSendJob extends SendJob {
return sharedContacts;
}
+ List getPreviewsFor(OutgoingMediaMessage mediaMessage) {
+ return Stream.of(mediaMessage.getLinkPreviews()).map(lp -> {
+ SignalServiceAttachment attachment = lp.getThumbnail().isPresent() ? getAttachmentPointerFor(lp.getThumbnail().get()) : null;
+ return new Preview(lp.getUrl(), lp.getTitle(), Optional.fromNullable(attachment));
+ }).toList();
+ }
+
protected void rotateSenderCertificateIfNecessary() throws IOException {
try {
byte[] certificateBytes = TextSecurePreferences.getUnidentifiedAccessCertificate(context);
diff --git a/src/org/thoughtcrime/securesms/linkpreview/LinkPreview.java b/src/org/thoughtcrime/securesms/linkpreview/LinkPreview.java
new file mode 100644
index 0000000000..00368652d4
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/linkpreview/LinkPreview.java
@@ -0,0 +1,78 @@
+package org.thoughtcrime.securesms.linkpreview;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import org.thoughtcrime.securesms.attachments.Attachment;
+import org.thoughtcrime.securesms.attachments.AttachmentId;
+import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
+import org.thoughtcrime.securesms.util.JsonUtils;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.io.IOException;
+
+public class LinkPreview {
+
+ @JsonProperty
+ private final String url;
+
+ @JsonProperty
+ private final String title;
+
+ @JsonProperty
+ private final AttachmentId attachmentId;
+
+ @JsonIgnore
+ private final Optional thumbnail;
+
+ public LinkPreview(@NonNull String url, @NonNull String title, @NonNull DatabaseAttachment thumbnail) {
+ this.url = url;
+ this.title = title;
+ this.thumbnail = Optional.of(thumbnail);
+ this.attachmentId = thumbnail.getAttachmentId();
+ }
+
+ public LinkPreview(@NonNull String url, @NonNull String title, @NonNull Optional thumbnail) {
+ this.url = url;
+ this.title = title;
+ this.thumbnail = thumbnail;
+ this.attachmentId = null;
+ }
+
+ public LinkPreview(@JsonProperty("url") @NonNull String url,
+ @JsonProperty("title") @NonNull String title,
+ @JsonProperty("attachmentId") @Nullable AttachmentId attachmentId)
+ {
+ this.url = url;
+ this.title = title;
+ this.attachmentId = attachmentId;
+ this.thumbnail = Optional.absent();
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public Optional getThumbnail() {
+ return thumbnail;
+ }
+
+ public @Nullable AttachmentId getAttachmentId() {
+ return attachmentId;
+ }
+
+ public String serialize() throws IOException {
+ return JsonUtils.toJson(this);
+ }
+
+ public static LinkPreview deserialize(@NonNull String serialized) throws IOException {
+ return JsonUtils.fromJson(serialized, LinkPreview.class);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewDomains.java b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewDomains.java
new file mode 100644
index 0000000000..da6a1fa824
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewDomains.java
@@ -0,0 +1,30 @@
+package org.thoughtcrime.securesms.linkpreview;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+public class LinkPreviewDomains {
+ public static final Set LINKS = new HashSet<>(Arrays.asList(
+ "youtube.com",
+ "www.youtube.com",
+ "m.youtube.com",
+ "youtu.be",
+ "reddit.com",
+ "www.reddit.com",
+ "m.reddit.com",
+ "imgur.com",
+ "www.imgur.com",
+ "m.imgur.com",
+ "instagram.com",
+ "www.instagram.com",
+ "m.instagram.com"
+ ));
+
+ public static final Set IMAGES = new HashSet<>(Arrays.asList(
+ "ytimg.com",
+ "cdninstagram.com",
+ "redd.it",
+ "imgur.com"
+ ));
+}
diff --git a/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java
new file mode 100644
index 0000000000..2e76b55bf7
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java
@@ -0,0 +1,215 @@
+package org.thoughtcrime.securesms.linkpreview;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.text.Html;
+import android.text.TextUtils;
+
+import com.bumptech.glide.load.engine.DiskCacheStrategy;
+import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
+import com.bumptech.glide.request.FutureTarget;
+
+import org.thoughtcrime.securesms.attachments.Attachment;
+import org.thoughtcrime.securesms.attachments.UriAttachment;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.net.CallRequestController;
+import org.thoughtcrime.securesms.net.CompositeRequestController;
+import org.thoughtcrime.securesms.net.ContentProxySelector;
+import org.thoughtcrime.securesms.net.RequestController;
+import org.thoughtcrime.securesms.providers.MemoryBlobProvider;
+import org.thoughtcrime.securesms.util.MediaUtil;
+import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import okhttp3.CacheControl;
+import okhttp3.Call;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class LinkPreviewRepository {
+
+ private static final String TAG = LinkPreviewRepository.class.getSimpleName();
+
+ private static final CacheControl NO_CACHE = new CacheControl.Builder().noCache().build();
+
+ private final OkHttpClient client;
+
+ public LinkPreviewRepository() {
+ this.client = new OkHttpClient.Builder()
+ .proxySelector(new ContentProxySelector())
+ .cache(null)
+ .build();
+ }
+
+ RequestController getLinkPreview(@NonNull Context context, @NonNull String url, @NonNull Callback> callback) {
+ CompositeRequestController compositeController = new CompositeRequestController();
+
+ if (!LinkPreviewUtil.isWhitelistedLinkUrl(url)) {
+ Log.w(TAG, "Tried to get a link preview for a non-whitelisted domain.");
+ callback.onComplete(Optional.absent());
+ return compositeController;
+ }
+
+ RequestController metadataController = fetchMetadata(url, metadata -> {
+ if (metadata.isEmpty()) {
+ callback.onComplete(Optional.absent());
+ return;
+ }
+
+ if (!metadata.getImageUrl().isPresent()) {
+ callback.onComplete(Optional.of(new LinkPreview(url, metadata.getTitle().get(), Optional.absent())));
+ return;
+ }
+
+ RequestController imageController = fetchThumbnail(context, metadata.getImageUrl().get(), attachment -> {
+ if (!metadata.getTitle().isPresent() && !attachment.isPresent()) {
+ callback.onComplete(Optional.absent());
+ } else {
+ callback.onComplete(Optional.of(new LinkPreview(url, metadata.getTitle().or(""), attachment)));
+ }
+ });
+
+ compositeController.addController(imageController);
+ });
+
+ compositeController.addController(metadataController);
+ return compositeController;
+ }
+
+ private @NonNull RequestController fetchMetadata(@NonNull String url, Callback callback) {
+ Call call = client.newCall(new Request.Builder().url(url).cacheControl(NO_CACHE).build());
+
+ call.enqueue(new okhttp3.Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ Log.w(TAG, "Request failed.", e);
+ callback.onComplete(Metadata.empty());
+ }
+
+ @Override
+ public void onResponse(Call call, Response response) throws IOException {
+ if (!response.isSuccessful()) {
+ Log.w(TAG, "Non-successful response. Code: " + response.code());
+ callback.onComplete(Metadata.empty());
+ return;
+ } else if (response.body() == null) {
+ Log.w(TAG, "No response body.");
+ callback.onComplete(Metadata.empty());
+ return;
+ }
+
+ String body = response.body().string();
+ Optional title = getProperty(body, "title");
+ Optional imageUrl = getProperty(body, "image");
+
+ if (imageUrl.isPresent() && !LinkPreviewUtil.isWhitelistedMediaUrl(imageUrl.get())) {
+ Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping.");
+ imageUrl = Optional.absent();
+ }
+
+ callback.onComplete(new Metadata(title, imageUrl));
+ }
+ });
+
+ return new CallRequestController(call);
+ }
+
+ private @NonNull RequestController fetchThumbnail(@NonNull Context context, @NonNull String imageUrl, @NonNull Callback> callback) {
+ FutureTarget bitmapFuture = GlideApp.with(context).asBitmap()
+ .load(new ChunkedImageUrl(imageUrl))
+ .skipMemoryCache(true)
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
+ .downsample(DownsampleStrategy.AT_MOST)
+ .submit(1024, 1024);
+
+ RequestController controller = () -> bitmapFuture.cancel(false);
+
+ SignalExecutors.IO.execute(() -> {
+ try {
+ Bitmap bitmap = bitmapFuture.get();
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos);
+
+ byte[] bytes = baos.toByteArray();
+ Uri uri = MemoryBlobProvider.getInstance().createUri(bytes);
+ Optional thumbnail = Optional.of(new UriAttachment(uri,
+ uri,
+ MediaUtil.IMAGE_JPEG,
+ AttachmentDatabase.TRANSFER_PROGRESS_STARTED,
+ bytes.length,
+ bitmap.getWidth(),
+ bitmap.getHeight(),
+ null,
+ null,
+ false,
+ false,
+ null));
+
+ callback.onComplete(thumbnail);
+ } catch (CancellationException | ExecutionException | InterruptedException e) {
+ controller.cancel();
+ callback.onComplete(Optional.absent());
+ } finally {
+ bitmapFuture.cancel(false);
+ }
+ });
+
+ return () -> bitmapFuture.cancel(true);
+ }
+
+ 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*/?\\s*>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+ Matcher matcher = pattern.matcher(searchText);
+
+ if (matcher.find()) {
+ String text = Html.fromHtml(matcher.group(1)).toString();
+ return TextUtils.isEmpty(text) ? Optional.absent() : Optional.of(text);
+ }
+
+ return Optional.absent();
+ }
+
+ private static class Metadata {
+ private final Optional title;
+ private final Optional imageUrl;
+
+ Metadata(Optional title, Optional imageUrl) {
+ this.title = title;
+ this.imageUrl = imageUrl;
+ }
+
+ static Metadata empty() {
+ return new Metadata(Optional.absent(), Optional.absent());
+ }
+
+ Optional getTitle() {
+ return title;
+ }
+
+ Optional getImageUrl() {
+ return imageUrl;
+ }
+
+ boolean isEmpty() {
+ return !title.isPresent() && !imageUrl.isPresent();
+ }
+ }
+
+ interface Callback {
+ void onComplete(@NonNull T result);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java
new file mode 100644
index 0000000000..41b8dfd18b
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java
@@ -0,0 +1,56 @@
+package org.thoughtcrime.securesms.linkpreview;
+
+import android.support.annotation.NonNull;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.style.URLSpan;
+import android.text.util.Linkify;
+
+import com.annimon.stream.Stream;
+
+import java.util.Collections;
+import java.util.List;
+
+import okhttp3.HttpUrl;
+
+public final class LinkPreviewUtil {
+
+ /**
+ * @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();
+ }
+
+ return Stream.of(spannable.getSpans(0, spannable.length(), URLSpan.class))
+ .map(URLSpan::getURL)
+ .filter(LinkPreviewUtil::isWhitelistedLinkUrl)
+ .toList();
+ }
+
+ /**
+ * @return True if the host is present in the link whitelist.
+ */
+ public static boolean isWhitelistedLinkUrl(@NonNull String linkUrl) {
+ HttpUrl url = HttpUrl.parse(linkUrl);
+ return url != null &&
+ !TextUtils.isEmpty(url.scheme()) &&
+ "https".equals(url.scheme()) &&
+ LinkPreviewDomains.LINKS.contains(url.host());
+ }
+
+ /**
+ * @return True if the top-level domain is present in the media whitelist.
+ */
+ public static boolean isWhitelistedMediaUrl(@NonNull String mediaUrl) {
+ HttpUrl url = HttpUrl.parse(mediaUrl);
+ return url != null &&
+ !TextUtils.isEmpty(url.scheme()) &&
+ "https".equals(url.scheme()) &&
+ LinkPreviewDomains.IMAGES.contains(url.topPrivateDomain());
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java
new file mode 100644
index 0000000000..35cd834711
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java
@@ -0,0 +1,191 @@
+package org.thoughtcrime.securesms.linkpreview;
+
+import android.arch.lifecycle.LiveData;
+import android.arch.lifecycle.MutableLiveData;
+import android.arch.lifecycle.ViewModel;
+import android.arch.lifecycle.ViewModelProvider;
+import android.content.Context;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+
+import org.thoughtcrime.securesms.attachments.Attachment;
+import org.thoughtcrime.securesms.attachments.UriAttachment;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.net.RequestController;
+import org.thoughtcrime.securesms.providers.MemoryBlobProvider;
+import org.thoughtcrime.securesms.providers.PersistentBlobProvider;
+import org.thoughtcrime.securesms.util.Debouncer;
+import org.thoughtcrime.securesms.util.MediaUtil;
+import org.thoughtcrime.securesms.util.Util;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.Collections;
+import java.util.List;
+
+
+public class LinkPreviewViewModel extends ViewModel {
+
+ private final LinkPreviewRepository repository;
+ private final MutableLiveData linkPreviewState;
+
+ private String activeUrl;
+ private RequestController activeRequest;
+ private boolean userCanceled;
+ private Debouncer debouncer;
+
+ private LinkPreviewViewModel(@NonNull LinkPreviewRepository repository) {
+ this.repository = repository;
+ this.linkPreviewState = new MutableLiveData<>();
+ this.debouncer = new Debouncer(250);
+ }
+
+ public LiveData getLinkPreviewState() {
+ return linkPreviewState;
+ }
+
+ public boolean hasLinkPreview() {
+ return linkPreviewState.getValue() != null && linkPreviewState.getValue().getLinkPreview().isPresent();
+ }
+
+ public @NonNull List getPersistedLinkPreviews(@NonNull Context context) {
+ final LinkPreviewState state = linkPreviewState.getValue();
+ if (state == null || !state.getLinkPreview().isPresent()) {
+ return Collections.emptyList();
+ }
+
+ if (!state.getLinkPreview().get().getThumbnail().isPresent() || state.getLinkPreview().get().getThumbnail().get().getDataUri() == null) {
+ return Collections.singletonList(state.getLinkPreview().get());
+ }
+
+ LinkPreview originalPreview = state.getLinkPreview().get();
+ Attachment originalAttachment = originalPreview.getThumbnail().get();
+ Uri memoryUri = originalAttachment.getDataUri();
+ byte[] imageBlob = MemoryBlobProvider.getInstance().getBlob(memoryUri);
+ Uri diskUri = PersistentBlobProvider.getInstance(context).create(context, imageBlob, MediaUtil.IMAGE_JPEG, null);
+ Attachment newAttachment = new UriAttachment(diskUri,
+ diskUri,
+ originalAttachment.getContentType(),
+ originalAttachment.getTransferState(),
+ originalAttachment.getSize(),
+ originalAttachment.getWidth(),
+ originalAttachment.getHeight(),
+ originalAttachment.getFileName(),
+ originalAttachment.getFastPreflightId(),
+ originalAttachment.isVoiceNote(),
+ originalAttachment.isQuote(),
+ originalAttachment.getCaption());
+
+ MemoryBlobProvider.getInstance().delete(memoryUri);
+
+ return Collections.singletonList(new LinkPreview(originalPreview.getUrl(), originalPreview.getTitle(), Optional.of(newAttachment)));
+ }
+
+ public void onTextChanged(@NonNull Context context, @NonNull String text) {
+ debouncer.publish(() -> {
+ if (userCanceled) {
+ return;
+ }
+
+ List urls = LinkPreviewUtil.findWhitelistedUrls(text);
+ Optional url = urls.isEmpty() ? Optional.absent() : Optional.of(urls.get(0));
+
+ if (url.isPresent() && url.get().equals(activeUrl)) {
+ return;
+ }
+
+ if (activeRequest != null) {
+ activeRequest.cancel();
+ activeRequest = null;
+ }
+
+ if (!url.isPresent()) {
+ activeUrl = null;
+ linkPreviewState.setValue(LinkPreviewState.forEmpty());
+ return;
+ }
+
+ linkPreviewState.setValue(LinkPreviewState.forLoading());
+
+ activeUrl = url.get();
+ activeRequest = repository.getLinkPreview(context, url.get(), lp -> {
+ Util.runOnMain(() -> {
+ if (!userCanceled) {
+ linkPreviewState.setValue(LinkPreviewState.forPreview(lp));
+ }
+ activeRequest = null;
+ });
+ });
+ });
+ }
+
+
+ public void onUserCancel() {
+ if (activeRequest != null) {
+ activeRequest.cancel();
+ activeRequest = null;
+ }
+
+ userCanceled = true;
+ activeUrl = null;
+
+ debouncer.clear();
+ linkPreviewState.setValue(LinkPreviewState.forEmpty());
+ }
+
+ public void onEnabled() {
+ userCanceled = false;
+ }
+
+ @Override
+ protected void onCleared() {
+ if (activeRequest != null) {
+ activeRequest.cancel();
+ }
+
+ debouncer.clear();
+ }
+
+ public static class LinkPreviewState {
+ private final boolean isLoading;
+ private final Optional linkPreview;
+
+ private LinkPreviewState(boolean isLoading, Optional linkPreview) {
+ this.isLoading = isLoading;
+ this.linkPreview = linkPreview;
+ }
+
+ private static LinkPreviewState forLoading() {
+ return new LinkPreviewState(true, Optional.absent());
+ }
+
+ private static LinkPreviewState forPreview(@NonNull Optional linkPreview) {
+ return new LinkPreviewState(false, linkPreview);
+ }
+
+ private static LinkPreviewState forEmpty() {
+ return new LinkPreviewState(false, Optional.absent());
+ }
+
+ public boolean isLoading() {
+ return isLoading;
+ }
+
+ public Optional getLinkPreview() {
+ return linkPreview;
+ }
+ }
+
+ public static class Factory extends ViewModelProvider.NewInstanceFactory {
+
+ private final LinkPreviewRepository repository;
+
+ public Factory(@NonNull LinkPreviewRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ public @NonNull T create(@NonNull Class modelClass) {
+ return modelClass.cast(new LinkPreviewViewModel(repository));
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java b/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java
index 35f6f08585..10d372f838 100644
--- a/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java
+++ b/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java
@@ -4,6 +4,7 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.PointerAttachment;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.Address;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@@ -26,8 +27,9 @@ public class IncomingMediaMessage {
private final QuoteModel quote;
private final boolean unidentified;
- private final List attachments = new LinkedList<>();
- private final List sharedContacts = new LinkedList<>();
+ private final List attachments = new LinkedList<>();
+ private final List sharedContacts = new LinkedList<>();
+ private final List linkPreviews = new LinkedList<>();
public IncomingMediaMessage(Address from,
Optional groupId,
@@ -63,7 +65,8 @@ public class IncomingMediaMessage {
Optional group,
Optional> attachments,
Optional quote,
- Optional> sharedContacts)
+ Optional> sharedContacts,
+ Optional> linkPreviews)
{
this.push = true;
this.from = from;
@@ -80,6 +83,7 @@ public class IncomingMediaMessage {
this.attachments.addAll(PointerAttachment.forPointers(attachments));
this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList()));
+ this.linkPreviews.addAll(linkPreviews.or(Collections.emptyList()));
}
public int getSubscriptionId() {
@@ -130,6 +134,10 @@ public class IncomingMediaMessage {
return sharedContacts;
}
+ public List getLinkPreviews() {
+ return linkPreviews;
+ }
+
public boolean isUnidentified() {
return unidentified;
}
diff --git a/src/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java b/src/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java
index a5ab0f5673..3951cb0aab 100644
--- a/src/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java
+++ b/src/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java
@@ -11,7 +11,8 @@ public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage
public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn) {
super(recipient, "", new LinkedList(), sentTimeMillis,
- ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, null, Collections.emptyList());
+ ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, null, Collections.emptyList(),
+ Collections.emptyList());
}
@Override
diff --git a/src/org/thoughtcrime/securesms/mms/OutgoingGroupMediaMessage.java b/src/org/thoughtcrime/securesms/mms/OutgoingGroupMediaMessage.java
index 2952ee527c..3391316904 100644
--- a/src/org/thoughtcrime/securesms/mms/OutgoingGroupMediaMessage.java
+++ b/src/org/thoughtcrime/securesms/mms/OutgoingGroupMediaMessage.java
@@ -6,6 +6,7 @@ import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.ThreadDatabase;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Base64;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
@@ -24,11 +25,12 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage {
long sentTimeMillis,
long expiresIn,
@Nullable QuoteModel quote,
- @NonNull List contacts)
+ @NonNull List contacts,
+ @NonNull List previews)
throws IOException
{
super(recipient, encodedGroupContext, avatar, sentTimeMillis,
- ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, quote, contacts);
+ ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, quote, contacts, previews);
this.group = GroupContext.parseFrom(Base64.decode(encodedGroupContext));
}
@@ -39,12 +41,13 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage {
long sentTimeMillis,
long expireIn,
@Nullable QuoteModel quote,
- @NonNull List contacts)
+ @NonNull List contacts,
+ @NonNull List previews)
{
super(recipient, Base64.encodeBytes(group.toByteArray()),
new LinkedList() {{if (avatar != null) add(avatar);}},
System.currentTimeMillis(),
- ThreadDatabase.DistributionTypes.CONVERSATION, expireIn, quote, contacts);
+ ThreadDatabase.DistributionTypes.CONVERSATION, expireIn, quote, contacts, previews);
this.group = group;
}
diff --git a/src/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java b/src/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java
index 1036f11da1..f2f0cf478d 100644
--- a/src/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java
+++ b/src/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.LinkedList;
@@ -27,6 +28,7 @@ public class OutgoingMediaMessage {
private final List networkFailures = new LinkedList<>();
private final List identityKeyMismatches = new LinkedList<>();
private final List contacts = new LinkedList<>();
+ private final List linkPreviews = new LinkedList<>();
public OutgoingMediaMessage(Recipient recipient, String message,
List attachments, long sentTimeMillis,
@@ -34,6 +36,7 @@ public class OutgoingMediaMessage {
int distributionType,
@Nullable QuoteModel outgoingQuote,
@NonNull List contacts,
+ @NonNull List linkPreviews,
@NonNull List networkFailures,
@NonNull List identityKeyMismatches)
{
@@ -47,18 +50,22 @@ public class OutgoingMediaMessage {
this.outgoingQuote = outgoingQuote;
this.contacts.addAll(contacts);
+ this.linkPreviews.addAll(linkPreviews);
this.networkFailures.addAll(networkFailures);
this.identityKeyMismatches.addAll(identityKeyMismatches);
}
- public OutgoingMediaMessage(Recipient recipient, SlideDeck slideDeck, String message, long sentTimeMillis, int subscriptionId, long expiresIn, int distributionType, @Nullable QuoteModel outgoingQuote, @NonNull List contacts)
+ public OutgoingMediaMessage(Recipient recipient, SlideDeck slideDeck, String message,
+ long sentTimeMillis, int subscriptionId, long expiresIn,
+ int distributionType, @Nullable QuoteModel outgoingQuote,
+ @NonNull List contacts, @NonNull List linkPreviews)
{
this(recipient,
buildMessage(slideDeck, message),
slideDeck.asAttachments(),
sentTimeMillis, subscriptionId,
expiresIn, distributionType, outgoingQuote,
- contacts, new LinkedList<>(), new LinkedList<>());
+ contacts, linkPreviews, new LinkedList<>(), new LinkedList<>());
}
public OutgoingMediaMessage(OutgoingMediaMessage that) {
@@ -74,6 +81,7 @@ public class OutgoingMediaMessage {
this.identityKeyMismatches.addAll(that.identityKeyMismatches);
this.networkFailures.addAll(that.networkFailures);
this.contacts.addAll(that.contacts);
+ this.linkPreviews.addAll(that.linkPreviews);
}
public Recipient getRecipient() {
@@ -124,6 +132,10 @@ public class OutgoingMediaMessage {
return contacts;
}
+ public @NonNull List getLinkPreviews() {
+ return linkPreviews;
+ }
+
public @NonNull List getNetworkFailures() {
return networkFailures;
}
diff --git a/src/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java b/src/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java
index cbed4891e4..434fa92171 100644
--- a/src/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java
+++ b/src/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java
@@ -5,6 +5,7 @@ import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.contactshare.Contact;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Collections;
@@ -18,9 +19,10 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage {
int distributionType,
long expiresIn,
@Nullable QuoteModel quote,
- @NonNull List contacts)
+ @NonNull List contacts,
+ @NonNull List previews)
{
- super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, distributionType, quote, contacts, Collections.emptyList(), Collections.emptyList());
+ super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList());
}
public OutgoingSecureMediaMessage(OutgoingMediaMessage base) {
diff --git a/src/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/src/org/thoughtcrime/securesms/mms/SignalGlideModule.java
index 888e39c3b1..6524a96eb9 100644
--- a/src/org/thoughtcrime/securesms/mms/SignalGlideModule.java
+++ b/src/org/thoughtcrime/securesms/mms/SignalGlideModule.java
@@ -23,14 +23,14 @@ import com.bumptech.glide.module.AppGlideModule;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
-import org.thoughtcrime.securesms.giph.model.GiphyPaddedUrl;
+import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl;
import org.thoughtcrime.securesms.glide.ContactPhotoLoader;
import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapCacheDecoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedCacheEncoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedGifCacheDecoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapResourceEncoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedGifDrawableResourceEncoder;
-import org.thoughtcrime.securesms.glide.GiphyPaddedUrlLoader;
+import org.thoughtcrime.securesms.glide.ChunkedImageUrlLoader;
import org.thoughtcrime.securesms.glide.OkHttpUrlLoader;
import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
@@ -68,7 +68,7 @@ public class SignalGlideModule extends AppGlideModule {
registry.append(ContactPhoto.class, InputStream.class, new ContactPhotoLoader.Factory(context));
registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context));
registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory());
- registry.append(GiphyPaddedUrl.class, InputStream.class, new GiphyPaddedUrlLoader.Factory());
+ registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory());
registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory());
}
diff --git a/src/org/thoughtcrime/securesms/net/CallRequestController.java b/src/org/thoughtcrime/securesms/net/CallRequestController.java
new file mode 100644
index 0000000000..f1f82ea1e7
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/net/CallRequestController.java
@@ -0,0 +1,60 @@
+package org.thoughtcrime.securesms.net;
+
+import android.os.AsyncTask;
+import android.support.annotation.NonNull;
+import android.support.annotation.WorkerThread;
+
+import org.thoughtcrime.securesms.util.Util;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.io.InputStream;
+
+import okhttp3.Call;
+
+public class CallRequestController implements RequestController {
+
+ private final Call call;
+
+ private InputStream stream;
+ private boolean canceled;
+
+ public CallRequestController(@NonNull Call call) {
+ this.call = call;
+ }
+
+ @Override
+ public void cancel() {
+ AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
+ synchronized (CallRequestController.this) {
+ call.cancel();
+
+ if (stream != null) {
+ Util.close(stream);
+ }
+
+ canceled = true;
+ }
+ });
+ }
+
+ public synchronized void setStream(@NonNull InputStream stream) {
+ if (canceled) {
+ Util.close(stream);
+ } else {
+ this.stream = stream;
+ }
+ notifyAll();
+ }
+
+ /**
+ * Blocks until the stream is available or until the request is canceled.
+ */
+ @WorkerThread
+ public synchronized Optional getStream() {
+ while(stream == null && !canceled) {
+ Util.wait(this, 0);
+ }
+
+ return Optional.fromNullable(this.stream);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java b/src/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java
new file mode 100644
index 0000000000..b27fe4ab36
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java
@@ -0,0 +1,350 @@
+package org.thoughtcrime.securesms.net;
+
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+
+import com.annimon.stream.Stream;
+import com.bumptech.glide.util.ContentLengthInputStream;
+
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import okhttp3.CacheControl;
+import okhttp3.Call;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class ChunkedDataFetcher {
+
+ private static final String TAG = ChunkedDataFetcher.class.getSimpleName();
+
+ private static final CacheControl NO_CACHE = new CacheControl.Builder().noCache().build();
+
+ private static final long MB = 1024 * 1024;
+ private static final long KB = 1024;
+
+ private final OkHttpClient client;
+
+ public ChunkedDataFetcher(@NonNull OkHttpClient client) {
+ this.client = client;
+ }
+
+ public RequestController fetch(@NonNull String url, long contentLength, @NonNull Callback callback) {
+ if (contentLength <= 0) {
+ return fetch(url, callback);
+ }
+
+ CompositeRequestController compositeController = new CompositeRequestController();
+ fetchChunks(url, contentLength, compositeController, callback);
+ return compositeController;
+ }
+
+ public RequestController fetch(@NonNull String url, @NonNull Callback callback) {
+ CompositeRequestController compositeController = new CompositeRequestController();
+
+ Call headCall = client.newCall(new Request.Builder().url(url).head().cacheControl(NO_CACHE).build());
+ compositeController.addController(new CallRequestController(headCall));
+
+ headCall.enqueue(new okhttp3.Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ if (!compositeController.isCanceled()) {
+ callback.onFailure(e);
+ compositeController.cancel();
+ }
+ }
+
+ @Override
+ public void onResponse(Call call, Response response) throws IOException {
+ String contentLength = response.header("Content-Length");
+ String acceptRanges = response.header("Accept-Ranges");
+
+ if (!response.isSuccessful()) {
+ Log.w(TAG, "Non-successful response code: " + response.code());
+ callback.onFailure(new IOException("Non-successful response code: " + response.code()));
+ compositeController.cancel();
+ if (response.body() != null) response.body().close();
+ return;
+ }
+
+ if (TextUtils.isEmpty(contentLength)) {
+ Log.w(TAG, "Missing Content-Length header.");
+ callback.onFailure(new IOException("Missing Content-Length header."));
+ compositeController.cancel();
+ if (response.body() != null) response.body().close();
+ return;
+ }
+
+ long parsedContentLength;
+ try {
+ parsedContentLength = Long.parseLong(contentLength);
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Invalid Content-Length value.");
+ callback.onFailure(new IOException("Invalid Content-Length value."));
+ compositeController.cancel();
+ return;
+ }
+
+ if (response.body() != null) {
+ response.body().close();
+ }
+
+ fetchChunks(url, parsedContentLength, compositeController, callback);
+ }
+ });
+
+ return compositeController;
+ }
+
+ private void fetchChunks(@NonNull String url, long contentLength, CompositeRequestController compositeController, Callback callback) {
+ List requestPattern;
+ try {
+ requestPattern = getRequestPattern(contentLength);
+ } catch (IOException e) {
+ callback.onFailure(e);
+ compositeController.cancel();
+ return;
+ }
+
+ SignalExecutors.IO.execute(() -> {
+ List controllers = Stream.of(requestPattern).map(range -> makeChunkRequest(client, url, range)).toList();
+ List streams = new ArrayList<>(controllers.size());
+
+ Stream.of(controllers).forEach(compositeController::addController);
+
+ for (CallRequestController controller : controllers) {
+ Optional stream = controller.getStream();
+
+ if (!stream.isPresent()) {
+ Log.w(TAG, "Stream was canceled.");
+ callback.onFailure(new IOException("Failure"));
+ compositeController.cancel();
+ return;
+ }
+
+ streams.add(stream.get());
+ }
+
+ try {
+ callback.onSuccess(new InputStreamList(streams));
+ } catch (IOException e) {
+ callback.onFailure(e);
+ compositeController.cancel();
+ }
+ });
+ }
+
+ private CallRequestController makeChunkRequest(@NonNull OkHttpClient client, @NonNull String url, @NonNull ByteRange range) {
+ Request request = new Request.Builder()
+ .url(url)
+ .cacheControl(NO_CACHE)
+ .addHeader("Range", "bytes=" + range.start + "-" + range.end)
+ .addHeader("Accept-Encoding", "identity")
+ .build();
+
+ Call call = client.newCall(request);
+ CallRequestController callController = new CallRequestController(call);
+
+ call.enqueue(new okhttp3.Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ callController.cancel();
+ }
+
+ @Override
+ public void onResponse(Call call, Response response) throws IOException {
+ if (!response.isSuccessful()) {
+ callController.cancel();
+ if (response.body() != null) response.body().close();
+ return;
+ }
+
+ if (response.body() == null) {
+ callController.cancel();
+ if (response.body() != null) response.body().close();
+ return;
+ }
+
+ InputStream stream = new SkippingInputStream(ContentLengthInputStream.obtain(response.body().byteStream(), response.body().contentLength()), range.ignoreFirst);
+ callController.setStream(stream);
+ }
+ });
+
+ return callController;
+ }
+
+ private List getRequestPattern(long size) throws IOException {
+ if (size > MB) return getRequestPattern(size, MB);
+ else if (size > 500 * KB) return getRequestPattern(size, 500 * KB);
+ else if (size > 100 * KB) return getRequestPattern(size, 100 * KB);
+ else if (size > 50 * KB) return getRequestPattern(size, 50 * KB);
+ else if (size > 10 * KB) return getRequestPattern(size, 10 * KB);
+ else if (size > KB) return getRequestPattern(size, KB);
+
+ throw new IOException("Unsupported size: " + size);
+ }
+
+ private List getRequestPattern(long size, long increment) {
+ List results = new LinkedList<>();
+
+ long offset = 0;
+
+ while (size - offset > increment) {
+ results.add(new ByteRange(offset, offset + increment - 1, 0));
+ offset += increment;
+ }
+
+ if (size - offset > 0) {
+ results.add(new ByteRange(size - increment, size-1, increment - (size - offset)));
+ }
+
+ return results;
+ }
+
+ private static class ByteRange {
+ private final long start;
+ private final long end;
+ private final long ignoreFirst;
+
+ private ByteRange(long start, long end, long ignoreFirst) {
+ this.start = start;
+ this.end = end;
+ this.ignoreFirst = ignoreFirst;
+ }
+ }
+
+ private static class SkippingInputStream extends FilterInputStream {
+
+ private long skip;
+
+ SkippingInputStream(InputStream in, long skip) {
+ super(in);
+ this.skip = skip;
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (skip != 0) {
+ skipFully(skip);
+ skip = 0;
+ }
+
+ return super.read();
+ }
+
+ @Override
+ public int read(@NonNull byte[] buffer) throws IOException {
+ if (skip != 0) {
+ skipFully(skip);
+ skip = 0;
+ }
+
+ return super.read(buffer);
+ }
+
+ @Override
+ public int read(@NonNull byte[] buffer, int offset, int length) throws IOException {
+ if (skip != 0) {
+ skipFully(skip);
+ skip = 0;
+ }
+
+ return super.read(buffer, offset, length);
+ }
+
+ @Override
+ public int available() throws IOException {
+ return Util.toIntExact(super.available() - skip);
+ }
+
+ private void skipFully(long amount) throws IOException {
+ byte[] buffer = new byte[4096];
+
+ while (amount > 0) {
+ int read = super.read(buffer, 0, Math.min(buffer.length, Util.toIntExact(amount)));
+
+ if (read != -1) amount -= read;
+ else return;
+ }
+ }
+ }
+
+ private static class InputStreamList extends InputStream {
+
+ private final List inputStreams;
+
+ private int currentStreamIndex = 0;
+
+ InputStreamList(List inputStreams) {
+ this.inputStreams = inputStreams;
+ }
+
+ @Override
+ public int read() throws IOException {
+ while (currentStreamIndex < inputStreams.size()) {
+ int result = inputStreams.get(currentStreamIndex).read();
+
+ if (result == -1) currentStreamIndex++;
+ else return result;
+ }
+
+ return -1;
+ }
+
+ @Override
+ public int read(@NonNull byte[] buffer, int offset, int length) throws IOException {
+ while (currentStreamIndex < inputStreams.size()) {
+ int result = inputStreams.get(currentStreamIndex).read(buffer, offset, length);
+
+ if (result == -1) currentStreamIndex++;
+ else return result;
+ }
+
+ return -1;
+ }
+
+ @Override
+ public int read(@NonNull byte[] buffer) throws IOException {
+ return read(buffer, 0, buffer.length);
+ }
+
+ @Override
+ public void close() throws IOException {
+ for (InputStream stream : inputStreams) {
+ try {
+ stream.close();
+ } catch (IOException ignored) {}
+ }
+ }
+
+ @Override
+ public int available() {
+ int total = 0;
+
+ for (int i=currentStreamIndex;i controllers = new ArrayList<>();
+ private boolean canceled = false;
+
+ public synchronized void addController(@NonNull RequestController controller) {
+ if (canceled) {
+ controller.cancel();
+ } else {
+ controllers.add(controller);
+ }
+ }
+
+ @Override
+ public synchronized void cancel() {
+ canceled = true;
+ Stream.of(controllers).forEach(RequestController::cancel);
+ }
+
+ public synchronized boolean isCanceled() {
+ return canceled;
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/net/ContentProxySelector.java b/src/org/thoughtcrime/securesms/net/ContentProxySelector.java
new file mode 100644
index 0000000000..60b31b1cee
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/net/ContentProxySelector.java
@@ -0,0 +1,84 @@
+package org.thoughtcrime.securesms.net;
+
+
+import android.os.AsyncTask;
+
+import org.thoughtcrime.securesms.linkpreview.LinkPreviewDomains;
+import org.thoughtcrime.securesms.logging.Log;
+
+import org.thoughtcrime.securesms.BuildConfig;
+import org.thoughtcrime.securesms.util.Util;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.net.ProxySelector;
+import java.net.SocketAddress;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class ContentProxySelector extends ProxySelector {
+
+ private static final String TAG = ContentProxySelector.class.getSimpleName();
+
+ public static final Set WHITELISTED_DOMAINS = new HashSet<>();
+ static {
+ WHITELISTED_DOMAINS.addAll(LinkPreviewDomains.LINKS);
+ WHITELISTED_DOMAINS.addAll(LinkPreviewDomains.IMAGES);
+ WHITELISTED_DOMAINS.add("giphy.com");
+ }
+
+ private final List EMPTY = new ArrayList<>(1);
+ private volatile List CONTENT = null;
+
+ public ContentProxySelector() {
+ EMPTY.add(Proxy.NO_PROXY);
+
+ if (Util.isMainThread()) {
+ AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
+ synchronized (ContentProxySelector.this) {
+ initializeContentProxy();
+ ContentProxySelector.this.notifyAll();
+ }
+ });
+ } else {
+ initializeContentProxy();
+ }
+ }
+
+ @Override
+ public List select(URI uri) {
+ for (String domain : WHITELISTED_DOMAINS) {
+ if (uri.getHost().endsWith(domain)) {
+ return getOrCreateContentProxy();
+ }
+ }
+ throw new IllegalArgumentException("Tried to proxy a non-whitelisted domain.");
+ }
+
+ @Override
+ public void connectFailed(URI uri, SocketAddress address, IOException failure) {
+ Log.w(TAG, failure);
+ }
+
+ private void initializeContentProxy() {
+ CONTENT = new ArrayList(1) {{
+ add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(BuildConfig.CONTENT_PROXY_HOST,
+ BuildConfig.CONTENT_PROXY_PORT)));
+ }};
+ }
+
+ private List getOrCreateContentProxy() {
+ if (CONTENT == null) {
+ synchronized (this) {
+ while (CONTENT == null) Util.wait(this, 0);
+ }
+ }
+
+ return CONTENT;
+ }
+
+}
diff --git a/src/org/thoughtcrime/securesms/net/RequestController.java b/src/org/thoughtcrime/securesms/net/RequestController.java
new file mode 100644
index 0000000000..0d667f4566
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/net/RequestController.java
@@ -0,0 +1,10 @@
+package org.thoughtcrime.securesms.net;
+
+public interface RequestController {
+
+ /**
+ * Best-effort cancellation of any outstanding requests. Will also release any resources held by
+ * the underlying request.
+ */
+ void cancel();
+}
diff --git a/src/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/src/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java
index 84406c4eac..c6c91ab02f 100644
--- a/src/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java
+++ b/src/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java
@@ -76,7 +76,7 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver {
if (recipient.isGroupRecipient()) {
Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message");
- OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
+ OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
replyThreadId = MessageSender.send(context, reply, threadId, false, null);
} else {
Log.w("AndroidAutoReplyReceiver", "Sending regular message ");
diff --git a/src/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/src/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java
index 5b45b5e3b8..48d7f4e867 100644
--- a/src/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java
+++ b/src/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java
@@ -72,7 +72,7 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
long expiresIn = recipient.getExpireMessages() * 1000L;
if (recipient.isGroupRecipient()) {
- OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
+ OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
threadId = MessageSender.send(context, reply, -1, false, null);
} else if (TextSecurePreferences.isPushRegistered(context) && recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
OutgoingEncryptedMessage reply = new OutgoingEncryptedMessage(recipient, responseText.toString(), expiresIn);
diff --git a/src/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java b/src/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java
index a7439813d6..a394692f99 100644
--- a/src/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java
+++ b/src/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java
@@ -66,6 +66,7 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
this.findPreference(TextSecurePreferences.PASSPHRASE_TIMEOUT_INTERVAL_PREF).setOnPreferenceClickListener(new PassphraseIntervalClickListener());
this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener());
this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener());
+ this.findPreference(TextSecurePreferences.LINK_PREVIEWS).setOnPreferenceChangeListener(new LinkPreviewToggleListener());
this.findPreference(PREFERENCE_CATEGORY_BLOCKED).setOnPreferenceClickListener(new BlockedContactsClickListener());
this.findPreference(TextSecurePreferences.SHOW_UNIDENTIFIED_DELIVERY_INDICATORS).setOnPreferenceChangeListener(new ShowUnidentifiedDeliveryIndicatorsChangedListener());
this.findPreference(TextSecurePreferences.UNIVERSAL_UNIDENTIFIED_ACCESS).setOnPreferenceChangeListener(new UniversalUnidentifiedAccessChangedListener());
@@ -189,7 +190,8 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
.add(new MultiDeviceConfigurationUpdateJob(getContext(),
enabled,
TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()),
- TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext())));
+ TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
+ TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
return true;
}
@@ -200,11 +202,12 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
public boolean onPreferenceChange(Preference preference, Object newValue) {
boolean enabled = (boolean)newValue;
ApplicationContext.getInstance(getContext())
- .getJobManager()
- .add(new MultiDeviceConfigurationUpdateJob(getContext(),
- TextSecurePreferences.isReadReceiptsEnabled(requireContext()),
- enabled,
- TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext())));
+ .getJobManager()
+ .add(new MultiDeviceConfigurationUpdateJob(getContext(),
+ TextSecurePreferences.isReadReceiptsEnabled(requireContext()),
+ enabled,
+ TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
+ TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
if (!enabled) {
ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().clear();
@@ -214,6 +217,22 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
}
}
+ private class LinkPreviewToggleListener implements Preference.OnPreferenceChangeListener {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ boolean enabled = (boolean)newValue;
+ ApplicationContext.getInstance(requireContext())
+ .getJobManager()
+ .add(new MultiDeviceConfigurationUpdateJob(requireContext(),
+ TextSecurePreferences.isReadReceiptsEnabled(requireContext()),
+ TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()),
+ TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(requireContext()),
+ enabled));
+
+ return true;
+ }
+ }
+
public static CharSequence getSummary(Context context) {
final int privacySummaryResId = R.string.ApplicationPreferencesActivity_privacy_summary;
final String onRes = context.getString(R.string.ApplicationPreferencesActivity_on);
@@ -307,11 +326,12 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
public boolean onPreferenceChange(Preference preference, Object newValue) {
boolean enabled = (boolean) newValue;
ApplicationContext.getInstance(getContext())
- .getJobManager()
- .add(new MultiDeviceConfigurationUpdateJob(getContext(),
- TextSecurePreferences.isReadReceiptsEnabled(getContext()),
- TextSecurePreferences.isTypingIndicatorsEnabled(getContext()),
- enabled));
+ .getJobManager()
+ .add(new MultiDeviceConfigurationUpdateJob(getContext(),
+ TextSecurePreferences.isReadReceiptsEnabled(getContext()),
+ TextSecurePreferences.isTypingIndicatorsEnabled(getContext()),
+ enabled,
+ TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
return true;
}
diff --git a/src/org/thoughtcrime/securesms/providers/MemoryBlobProvider.java b/src/org/thoughtcrime/securesms/providers/MemoryBlobProvider.java
index ed032b311f..c7a97c89cf 100644
--- a/src/org/thoughtcrime/securesms/providers/MemoryBlobProvider.java
+++ b/src/org/thoughtcrime/securesms/providers/MemoryBlobProvider.java
@@ -43,6 +43,21 @@ public class MemoryBlobProvider {
cache.remove(ContentUris.parseId(uri));
}
+ public synchronized @NonNull byte[] getBlob(@NonNull Uri uri) {
+ long id = ContentUris.parseId(uri);
+ Entry entry = cache.get(ContentUris.parseId(uri));
+
+ if (entry == null) {
+ throw new IllegalArgumentException("ID not found: " + id);
+ }
+
+ if (entry.isSingleUse()) {
+ cache.remove(id);
+ }
+
+ return entry.getBlob();
+ }
+
public synchronized @NonNull InputStream getStream(long id) throws IOException {
Entry entry = cache.get(id);
diff --git a/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java b/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java
index c5ea2834c1..56e16d6fb8 100644
--- a/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java
+++ b/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java
@@ -35,7 +35,7 @@ import org.thoughtcrime.securesms.scribbles.widget.entity.MotionEntity;
import org.thoughtcrime.securesms.scribbles.widget.entity.TextEntity;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
-import org.thoughtcrime.securesms.util.concurrent.LifecycleBoundTask;
+import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.whispersystems.libsignal.util.guava.Optional;
@@ -230,7 +230,7 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe
if (resultCode == RESULT_OK && requestCode == SELECT_STICKER_REQUEST_CODE && data != null) {
final String stickerFile = data.getStringExtra(StickerSelectActivity.EXTRA_STICKER_FILE);
- LifecycleBoundTask.run(getLifecycle(), () -> {
+ SimpleTask.run(getLifecycle(), () -> {
try {
return BitmapFactory.decodeStream(getContext().getAssets().open(stickerFile));
} catch (IOException e) {
diff --git a/src/org/thoughtcrime/securesms/util/GroupUtil.java b/src/org/thoughtcrime/securesms/util/GroupUtil.java
index 857a35eb2c..77b878e0ee 100644
--- a/src/org/thoughtcrime/securesms/util/GroupUtil.java
+++ b/src/org/thoughtcrime/securesms/util/GroupUtil.java
@@ -74,7 +74,7 @@ public class GroupUtil {
.setType(GroupContext.Type.QUIT)
.build();
- return Optional.of(new OutgoingGroupMediaMessage(groupRecipient, groupContext, null, System.currentTimeMillis(), 0, null, Collections.emptyList()));
+ return Optional.of(new OutgoingGroupMediaMessage(groupRecipient, groupContext, null, System.currentTimeMillis(), 0, null, Collections.emptyList(), Collections.emptyList()));
}
diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java
index 9f347c6ca4..457f4355dd 100644
--- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java
+++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java
@@ -173,6 +173,8 @@ public class TextSecurePreferences {
public static final String TYPING_INDICATORS = "pref_typing_indicators";
+ public static final String LINK_PREVIEWS = "pref_link_previews";
+
public static boolean isScreenLockEnabled(@NonNull Context context) {
return getBooleanPreference(context, SCREEN_LOCK, false);
}
@@ -346,6 +348,10 @@ public class TextSecurePreferences {
setBooleanPreference(context, TYPING_INDICATORS, enabled);
}
+ public static boolean isLinkPreviewsEnabled(Context context) {
+ return getBooleanPreference(context, LINK_PREVIEWS, true);
+ }
+
public static @Nullable String getProfileKey(Context context) {
return getStringPreference(context, PROFILE_KEY_PREF, null);
}
diff --git a/src/org/thoughtcrime/securesms/util/concurrent/LifecycleBoundTask.java b/src/org/thoughtcrime/securesms/util/concurrent/SimpleTask.java
similarity index 74%
rename from src/org/thoughtcrime/securesms/util/concurrent/LifecycleBoundTask.java
rename to src/org/thoughtcrime/securesms/util/concurrent/SimpleTask.java
index 143257a501..3cfb08ae90 100644
--- a/src/org/thoughtcrime/securesms/util/concurrent/LifecycleBoundTask.java
+++ b/src/org/thoughtcrime/securesms/util/concurrent/SimpleTask.java
@@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.util.Util;
import java.util.concurrent.Callable;
-public class LifecycleBoundTask {
+public class SimpleTask {
/**
* Runs a task in the background and passes the result of the computation to a task that is run
@@ -35,6 +35,17 @@ public class LifecycleBoundTask {
});
}
+ /**
+ * Runs a task in the background and passes the result of the computation to a task that is run on
+ * the main thread. Essentially {@link AsyncTask}, but lambda-compatible.
+ */
+ public static void run(@NonNull BackgroundTask backgroundTask, @NonNull ForegroundTask foregroundTask) {
+ AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
+ final E result = backgroundTask.run();
+ Util.runOnMain(() -> foregroundTask.run(result));
+ });
+ }
+
private static boolean isValid(@NonNull Lifecycle lifecycle) {
return lifecycle.getCurrentState().isAtLeast(Lifecycle.State.CREATED);
}