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 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 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); }