Added support for link previews.

master
Greyson Parrelli 2019-01-15 00:41:05 -08:00
parent bef9beff16
commit c76081d99c
88 changed files with 2687 additions and 678 deletions

View File

@ -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\""

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

View File

@ -45,6 +45,16 @@
app:quote_colorSecondary="?attr/conversation_item_sent_text_primary_color"
tools:visibility="visible"/>
<org.thoughtcrime.securesms.components.LinkPreviewView
android:id="@+id/link_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:layout_marginTop="6dp"
android:visibility="gone"
app:linkpreview_type="compose" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -128,6 +128,12 @@
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_received_thumbnail" />
<ViewStub
android:id="@+id/link_preview_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_received_link_preview" />
<ViewStub
android:id="@+id/audio_view_stub"
android:layout="@layout/conversation_item_received_audio"

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.LinkPreviewView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/link_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:linkpreview_type="conversation"
tools:visibility="visible" />

View File

@ -65,6 +65,12 @@
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_sent_thumbnail" />
<ViewStub
android:id="@+id/link_preview_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_sent_link_preview" />
<ViewStub
android:id="@+id/audio_view_stub"
android:layout="@layout/conversation_item_sent_audio"

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.LinkPreviewView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/link_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:linkpreview_type="conversation"
tools:visibility="visible" />

View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="#FF2090ea">
<TextView
android:id="@+id/blurb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:fontFamily="sans-serif-light"
android:gravity="center_horizontal"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:text="@string/ExperienceUpgradeActivity_introducing_link_previews"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="@android:color/white"
android:textIsSelectable="false"
android:textSize="@dimen/onboarding_title_size"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
android:src="@drawable/link_preview_splash"
app:layout_constraintBottom_toTopOf="@+id/linearLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_max="280dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/blurb"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintWidth_max="280dp" />
<LinearLayout
android:id="@+id/linearLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
android:orientation="vertical"
app:layout_constraintBottom_toTopOf="@+id/experience_ok_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"
android:gravity="center_horizontal"
android:paddingBottom="8dp"
android:text="@string/ExperienceUpgradeActivity_optional_link_previews_are_now_supported"
android:textColor="@color/core_white"
android:textIsSelectable="false"
android:textSize="@dimen/onboarding_subtitle_size" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"
android:gravity="center_horizontal"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:text="@string/ExperienceUpgradeActivity_you_can_disable_or_enable_this_feature_link_previews"
android:textColor="@color/core_white"
android:textIsSelectable="false"
android:textSize="16dp" />
</LinearLayout>
<android.support.v7.widget.AppCompatButton
android:id="@+id/experience_ok_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="24dp"
android:text="@string/ok"
android:textColor="@color/core_blue"
app:backgroundTint="@color/core_white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</android.support.constraint.ConstraintLayout>

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<android.support.constraint.ConstraintLayout
android:id="@+id/linkpreview_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp"
android:background="?linkpreview_background_color">
<org.thoughtcrime.securesms.components.OutlinedThumbnailView
android:id="@+id/linkpreview_thumbnail"
android:layout_width="72dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/linkpreview_divider"
app:layout_constraintHeight_min="72dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/linkpreview_title"
tools:src="@drawable/ic_contact_picture"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/linkpreview_title"
style="@style/Signal.Text.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:maxLines="2"
android:textColor="?linkpreview_primary_text_color"
app:layout_constraintEnd_toStartOf="@+id/linkpreview_close"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail"
app:layout_constraintTop_toTopOf="parent"
tools:text="Wall Crawler Strikes Again!" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/linkpreview_site"
style="@style/Signal.Text.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="2dp"
android:textAllCaps="true"
android:textColor="?linkpreview_secondary_text_color"
app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail"
app:layout_constraintTop_toBottomOf="@+id/linkpreview_title"
tools:text="dailybugle.com" />
<View
android:id="@+id/linkpreview_divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="6dp"
android:background="?linkpreview_divider_color"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/linkpreview_thumbnail"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail"
app:layout_constraintTop_toBottomOf="@+id/linkpreview_site"
app:layout_constraintVertical_bias="0.0"
tools:visibility="visible" />
<ImageView
android:id="@+id/linkpreview_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="6dp"
android:layout_marginEnd="6dp"
android:layout_marginTop="4dp"
android:src="@drawable/ic_close_white_18dp"
android:tint="@color/gray70"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<com.pnikosis.materialishprogress.ProgressWheel
android:id="@+id/linkpreview_progress_wheel"
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="8dp"
android:indeterminate="true"
android:padding="8dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/linkpreview_divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:matProg_barColor="@color/core_blue"
app:matProg_progressIndeterminate="true" />
</android.support.constraint.ConstraintLayout>
</merge>

View File

@ -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"

View File

@ -110,6 +110,11 @@
<attr name="invite_background" format="color"/>
<attr name="linkpreview_background_color" format="color" />
<attr name="linkpreview_primary_text_color" format="color" />
<attr name="linkpreview_secondary_text_color" format="color" />
<attr name="linkpreview_divider_color" format="color" />
<attr name="reminder_header_background" format="color"/>
<attr name="menu_new_conversation_icon" format="reference" />
@ -281,6 +286,13 @@
<attr name="contact_footerAlpha" format="float" />
</declare-styleable>
<declare-styleable name="LinkPreviewView">
<attr name="linkpreview_type" format="enum">
<enum name="conversation" value="0" />
<enum name="compose" value="1" />
</attr>
</declare-styleable>
<declare-styleable name="DocumentView">
<attr name="doc_titleColor" format="color" />
<attr name="doc_captionColor" format="color" />

View File

@ -51,6 +51,8 @@
<dimen name="mediasend_progress_dialog_size">120dp</dimen>
<dimen name="thumbnail_default_radius">4dp</dimen>
<dimen name="conversation_compose_height">40dp</dimen>
<dimen name="conversation_individual_right_gutter">16dp</dimen>
<dimen name="conversation_individual_left_gutter">16dp</dimen>

View File

@ -328,6 +328,10 @@
<string name="ExperienceUpgradeActivity_turn_on_typing_indicators">Turn on typing indicators</string>
<string name="ExperienceUpgradeActivity_no_thanks">No thanks</string>
<string name="ExperienceUpgradeActivity_introducing_link_previews">Introducing link previews.</string>
<string name="ExperienceUpgradeActivity_optional_link_previews_are_now_supported">Optional link previews are now supported for some of the most popular sites on the Internet.</string>
<string name="ExperienceUpgradeActivity_you_can_disable_or_enable_this_feature_link_previews">You can disable or enable this feature anytime in your Signal settings (Privacy &gt; Send link previews).</string>
<!-- GcmBroadcastReceiver -->
<string name="GcmBroadcastReceiver_retrieving_a_message">Retrieving a message...</string>
@ -1149,6 +1153,8 @@
<string name="preferences__use_signal_for_viewing_and_storing_all_incoming_multimedia_messages">Use Signal for all incoming multimedia messages</string>
<string name="preferences__pref_enter_sends_title">Enter key sends</string>
<string name="preferences__pressing_the_enter_key_will_send_text_messages">Pressing the Enter key will send text messages</string>
<string name="preferences__send_link_previews">Send link previews</string>
<string name="preferences__previews_are_supported_for">Previews are supported for Imgur, Instagram, Reddit, and YouTube links</string>
<string name="preferences__choose_identity">Choose identity</string>
<string name="preferences__choose_your_contact_entry_from_the_contacts_list">Choose your contact entry from the contacts list.</string>
<string name="preferences__change_passphrase">Change passphrase</string>

View File

@ -188,7 +188,6 @@
<item name="emoji_category_emoticons">@drawable/emoji_category_emoticons_light</item>
<item name="emoji_variation_selector_background">@drawable/emoji_variation_selector_background_light</item>
<item name="conversation_item_bubble_background">@color/core_grey_05</item>
<item name="conversation_item_sent_text_primary_color">@color/core_grey_90</item>
<item name="conversation_item_sent_text_secondary_color">@color/core_grey_60</item>
@ -223,6 +222,11 @@
<item name="import_export_item_background_shadow_color">@color/import_export_item_background_shadow_light</item>
<item name="import_export_item_card_background">@drawable/clickable_card_light</item>
<item name="linkpreview_background_color">@color/core_white</item>
<item name="linkpreview_primary_text_color">@color/core_black</item>
<item name="linkpreview_secondary_text_color">@color/core_grey_60</item>
<item name="linkpreview_divider_color">@color/core_grey_25</item>
<item name="menu_new_conversation_icon">@drawable/ic_add_white_24dp</item>
<item name="menu_group_icon">@drawable/ic_group_white_24dp</item>
<item name="menu_search_icon">@drawable/ic_search_white_24dp</item>
@ -373,6 +377,11 @@
<item name="emoji_category_emoticons">@drawable/emoji_category_emoticons_dark</item>
<item name="emoji_variation_selector_background">@drawable/emoji_variation_selector_background_dark</item>
<item name="linkpreview_background_color">@color/core_grey_95</item>
<item name="linkpreview_primary_text_color">@color/core_white</item>
<item name="linkpreview_secondary_text_color">@color/core_grey_25</item>
<item name="linkpreview_divider_color">@color/core_grey_60</item>
<item name="quick_camera_icon">@drawable/quick_camera_dark</item>
<item name="quick_mic_icon">@drawable/ic_mic_white_24dp</item>

View File

@ -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"/>
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat
android:defaultValue="true"
android:key="pref_link_previews"
android:summary="@string/preferences__previews_are_supported_for"
android:title="@string/preferences__send_link_previews"/>
<Preference android:key="preference_category_blocked"
android:title="@string/preferences_app_protection__blocked_contacts" />
</PreferenceCategory>

View File

@ -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"/>
</PreferenceCategory>
<PreferenceCategory android:layout="@layout/preference_divider"/>

View File

@ -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;

View File

@ -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<Recipient> choices);

View File

@ -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<Boolean>() {
@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<Void> sendMediaMessage(final boolean forceSms, String body, SlideDeck slideDeck, List<Contact> contacts, final long expiresIn, final int subscriptionId, final boolean initiating) {
private ListenableFuture<Void> sendMediaMessage(final boolean forceSms,
String body,
SlideDeck slideDeck,
List<Contact> contacts,
List<LinkPreview> 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<Void> 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<Integer> subscriptionId) {
new AsyncTask<Void, Void, Void>() {
@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<Void>() {
sendMediaMessage(forceSms, "", slideDeck, Collections.emptyList(), Collections.emptyList(), expiresIn, subscriptionId, initiating).addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void nothing) {
new AsyncTask<Void, Void, Void>() {
@ -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 {

View File

@ -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) {

View File

@ -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<AudioView> audioViewStub;
private @NonNull Stub<DocumentView> documentViewStub;
private @NonNull Stub<SharedContactView> sharedContactStub;
private @NonNull Stub<LinkPreviewView> 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<Slide> 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<MessageRecord> previous,
@NonNull Optional<MessageRecord> next,
boolean isGroupThread)
private void setThumbnailCorners(@NonNull MessageRecord current,
@NonNull Optional<MessageRecord> previous,
@NonNull Optional<MessageRecord> 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<MessageRecord> previous, @NonNull Optional<MessageRecord> 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<MessageRecord> previous, @NonNull Optional<MessageRecord> 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<Slide> slides) {

View File

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

View File

@ -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<IntroPage> 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;

View File

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

View File

@ -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;

View File

@ -62,6 +62,10 @@ public class TransportOptions {
}
public void setDefaultSubscriptionId(Optional<Integer> subscriptionId) {
if (defaultSubscriptionId.equals(subscriptionId)) {
return;
}
this.defaultSubscriptionId = subscriptionId;
if (!selectedOption.isPresent()) {

View File

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

View File

@ -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() {

View File

@ -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<QuoteModel> 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<LinkPreview> 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 {

View File

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

View File

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

View File

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

View File

@ -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)

View File

@ -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<Slide> slides) {

View File

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

View File

@ -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<Attachment> quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList();
List<Contact> contacts = getSharedContacts(cursor, associatedAttachments);
Set<Attachment> contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList());
List<Attachment> 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<Attachment> quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList();
List<Contact> contacts = getSharedContacts(cursor, associatedAttachments);
Set<Attachment> contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList());
List<LinkPreview> previews = getLinkPreviews(cursor, associatedAttachments);
Set<Attachment> previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet());
List<Attachment> 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<NetworkFailure> 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<LinkPreview> getLinkPreviews(@NonNull Cursor cursor, @NonNull List<DatabaseAttachment> attachments) {
String serializedPreviews = cursor.getString(cursor.getColumnIndexOrThrow(LINK_PREVIEWS));
if (TextUtils.isEmpty(serializedPreviews)) {
return Collections.emptyList();
}
Map<AttachmentId, DatabaseAttachment> attachmentIdMap = new HashMap<>();
for (DatabaseAttachment attachment : attachments) {
attachmentIdMap.put(attachment.getAttachmentId(), attachment);
}
try {
List<LinkPreview> 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<Recipient> members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(message.getRecipient().getAddress().toGroupString(), false);
@ -946,6 +993,7 @@ public class MmsDatabase extends MessagingDatabase {
@NonNull List<Attachment> attachments,
@NonNull List<Attachment> quoteAttachments,
@NonNull List<Contact> sharedContacts,
@NonNull List<LinkPreview> linkPreviews,
@NonNull ContentValues contentValues,
@Nullable SmsDatabase.InsertListener insertListener)
throws MmsException
@ -955,9 +1003,11 @@ public class MmsDatabase extends MessagingDatabase {
List<Attachment> allAttachments = new LinkedList<>();
List<Attachment> contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList();
List<Attachment> 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<Attachment, AttachmentId> 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<Attachment, AttachmentId> insertedAttachmentIds, @NonNull List<Contact> contacts) {
private @Nullable String getSerializedSharedContacts(@NonNull Map<Attachment, AttachmentId> insertedAttachmentIds, @NonNull List<Contact> 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<Attachment, AttachmentId> insertedAttachmentIds, @NonNull List<LinkPreview> 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<NetworkFailure> networkFailures = getFailures(networkDocument);
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor);
List<Contact> contacts = getSharedContacts(cursor, attachments);
Set<Attachment> 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<Attachment> contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).collect(Collectors.toSet());
List<LinkPreview> previews = getLinkPreviews(cursor, attachments);
Set<Attachment> 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) {

View File

@ -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<String> smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID);

View File

@ -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();

View File

@ -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<NetworkFailure> failures, int subscriptionId,
long expiresIn, long expireStarted, int readReceiptCount,
@Nullable Quote quote, @Nullable List<Contact> contacts,
boolean unidentified)
@Nullable List<LinkPreview> 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;

View File

@ -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<Contact> contacts = new LinkedList<>();
private final @NonNull SlideDeck slideDeck;
private final @Nullable Quote quote;
private final @NonNull List<Contact> contacts = new LinkedList<>();
private final @NonNull List<LinkPreview> 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<IdentityKeyMismatch> mismatches,
List<NetworkFailure> networkFailures, int subscriptionId, long expiresIn,
long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts, boolean unidentified)
@Nullable Quote quote, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> 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<Contact> getSharedContacts() {
return contacts;
}
public @NonNull List<LinkPreview> getLinkPreviews() {
return linkPreviews;
}
}

View File

@ -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<IdentityKeyMismatch>(), new LinkedList<NetworkFailure>(), 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;

View File

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

View File

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

View File

@ -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<List<GiphyImage>> {
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

View File

@ -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<Proxy> EMPTY = new ArrayList<>(1);
private volatile List<Proxy> GIPHY = null;
public GiphyProxySelector() {
EMPTY.add(Proxy.NO_PROXY);
if (Util.isMainThread()) {
new AsyncTask<Void, Void, Void>() {
@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<Proxy> 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<Proxy>(1) {{
add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(BuildConfig.GIPHY_PROXY_HOST,
BuildConfig.GIPHY_PROXY_PORT)));
}};
}
private List<Proxy> getOrCreateGiphyProxy() {
if (GIPHY == null) {
synchronized (this) {
while (GIPHY == null) Util.wait(this, 0);
}
}
return GIPHY;
}
}

View File

@ -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<GiphyAdapter.GiphyViewHolder> {
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<GiphyAdapter.GiphyViewHolder> {
@Override
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> 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<GiphyAdapter.GiphyViewHolder> {
}
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<GiphyAdapter.GiphyViewHolder> {
holder.gifProgress.setVisibility(View.GONE);
RequestBuilder<Drawable> 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);
}

View File

@ -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<InputStream> {
private static final String TAG = ChunkedImageUrlFetcher.class.getSimpleName();
private final OkHttpClient client;
private final ChunkedImageUrl url;
private RequestController requestController;
ChunkedImageUrlFetcher(@NonNull OkHttpClient client, @NonNull ChunkedImageUrl url) {
this.client = client;
this.url = url;
}
@Override
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
ChunkedDataFetcher fetcher = new ChunkedDataFetcher(client);
requestController = fetcher.fetch(url.getUrl(), url.getSize(), new ChunkedDataFetcher.Callback() {
@Override
public void onSuccess(InputStream stream) {
callback.onDataReady(stream);
}
@Override
public void onFailure(Exception e) {
callback.onLoadFailed(e);
}
});
}
@Override
public void cleanup() {
if (requestController != null) {
requestController.cancel();
}
}
@Override
public void cancel() {
Log.d(TAG, "Canceled.");
if (requestController != null) {
requestController.cancel();
}
}
@NonNull
@Override
public Class<InputStream> getDataClass() {
return InputStream.class;
}
@NonNull
@Override
public DataSource getDataSource() {
return DataSource.REMOTE;
}
}

View File

@ -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<ChunkedImageUrl, InputStream> {
private final OkHttpClient client;
private ChunkedImageUrlLoader(OkHttpClient client) {
this.client = client;
}
@Nullable
@Override
public LoadData<InputStream> 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<ChunkedImageUrl, InputStream> {
private final OkHttpClient client;
public Factory() {
this.client = new OkHttpClient.Builder()
.proxySelector(new ContentProxySelector())
.cache(null)
.build();
}
@Override
public ModelLoader<ChunkedImageUrl, InputStream> build(MultiModelLoaderFactory multiFactory) {
return new ChunkedImageUrlLoader(client);
}
@Override
public void teardown() {}
}
}

View File

@ -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<InputStream> {
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<ResponseBody> bodies;
private List<InputStream> rangeStreams;
private InputStream stream;
GiphyPaddedUrlFetcher(@NonNull OkHttpClient client,
@NonNull GiphyPaddedUrl url)
{
this.client = client;
this.url = url;
}
@Override
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
bodies = new LinkedList<>();
rangeStreams = new LinkedList<>();
stream = null;
try {
List<ByteRange> 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<InputStream> getDataClass() {
return InputStream.class;
}
@NonNull
@Override
public DataSource getDataSource() {
return DataSource.REMOTE;
}
private List<ByteRange> 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<ByteRange> getRequestPattern(long size, long increment) {
List<ByteRange> 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<InputStream> inputStreams;
private int currentStreamIndex = 0;
InputStreamList(List<InputStream> 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<inputStreams.size();i++) {
try {
int available = inputStreams.get(i).available();
if (available != -1) total += available;
} catch (IOException ignored) {}
}
return total;
}
}
}

View File

@ -1,52 +0,0 @@
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.GiphyPaddedUrl;
import org.thoughtcrime.securesms.giph.net.GiphyProxySelector;
import java.io.InputStream;
import okhttp3.OkHttpClient;
public class GiphyPaddedUrlLoader implements ModelLoader<GiphyPaddedUrl, InputStream> {
private final OkHttpClient client;
private GiphyPaddedUrlLoader(OkHttpClient client) {
this.client = client;
}
@Nullable
@Override
public LoadData<InputStream> 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<GiphyPaddedUrl, InputStream> {
private final OkHttpClient client;
public Factory() {
this.client = new OkHttpClient.Builder().proxySelector(new GiphyProxySelector()).build();
}
@Override
public ModelLoader<GiphyPaddedUrl, InputStream> build(MultiModelLoaderFactory multiFactory) {
return new GiphyPaddedUrlLoader(client);
}
@Override
public void teardown() {}
}
}

View File

@ -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<GlideUrl, InputStream> {
synchronized (Factory.class) {
if (internalClient == null) {
internalClient = new OkHttpClient.Builder()
.proxySelector(new GiphyProxySelector())
.proxySelector(new ContentProxySelector())
.build();
}
}

View File

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

View File

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

View File

@ -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());

View File

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

View File

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

View File

@ -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<QuoteModel> quote = getValidatedQuote(message.getQuote());
Optional<List<Contact>> 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<QuoteModel> quote = getValidatedQuote(message.getQuote());
Optional<List<Contact>> sharedContacts = getContacts(message.getSharedContacts());
Optional<List<LinkPreview>> 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> 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<QuoteModel> quote = getValidatedQuote(message.getMessage().getQuote());
Optional<List<Contact>> 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<QuoteModel> quote = getValidatedQuote(message.getMessage().getQuote());
Optional<List<Contact>> sharedContacts = getContacts(message.getMessage().getSharedContacts());
Optional<List<LinkPreview>> 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<Attachment> 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<List<LinkPreview>> getLinkPreviews(Optional<List<Preview>> previews, @NonNull String message) {
if (!previews.isPresent()) return Optional.absent();
List<LinkPreview> linkPreviews = new ArrayList<>(previews.get().size());
for (Preview preview : previews.get()) {
Optional<Attachment> thumbnail = PointerAttachment.forPointer(preview.getImage());
Optional<String> url = Optional.fromNullable(preview.getUrl());
Optional<String> 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<InsertResult> insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp) {
SmsDatabase database = DatabaseFactory.getSmsDatabase(context);
IncomingTextMessage textMessage = new IncomingTextMessage(Address.fromExternal(context, sender),

View File

@ -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<Attachment> 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<AttachmentUploadJob> attachmentJobs = Stream.of(message.getAttachments()).map(a -> new AttachmentUploadJob(context, ((DatabaseAttachment) a).getAttachmentId())).toList();
List<AttachmentUploadJob> 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<byte[]> profileKey = getProfileKey(message.getRecipient());
Optional<Quote> quote = getQuoteFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<Preview> previews = getPreviewsFor(message);
List<SignalServiceAddress> addresses = Stream.of(destinations).map(this::getPushAddress).toList();
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(message.getAttachments());
List<Optional<UnidentifiedAccessPair>> 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<Attachment> scaledAttachments = scaleAndStripExifFromAttachments(mediaConstraints, message.getAttachments());
List<SignalServiceAttachment> 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<SignalServiceAttachment> 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);

View File

@ -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<AttachmentUploadJob> 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<Attachment> 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<AttachmentUploadJob> 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<byte[]> profileKey = getProfileKey(message.getRecipient());
Optional<SignalServiceDataMessage.Quote> quote = getQuoteFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<Preview> 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();

View File

@ -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<Preview> 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);

View File

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

View File

@ -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<String> 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<String> IMAGES = new HashSet<>(Arrays.asList(
"ytimg.com",
"cdninstagram.com",
"redd.it",
"imgur.com"
));
}

View File

@ -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<Optional<LinkPreview>> 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<Metadata> 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<String> title = getProperty(body, "title");
Optional<String> 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<Optional<Attachment>> callback) {
FutureTarget<Bitmap> 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<Attachment> 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<String> 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<String> title;
private final Optional<String> imageUrl;
Metadata(Optional<String> title, Optional<String> imageUrl) {
this.title = title;
this.imageUrl = imageUrl;
}
static Metadata empty() {
return new Metadata(Optional.absent(), Optional.absent());
}
Optional<String> getTitle() {
return title;
}
Optional<String> getImageUrl() {
return imageUrl;
}
boolean isEmpty() {
return !title.isPresent() && !imageUrl.isPresent();
}
}
interface Callback<T> {
void onComplete(@NonNull T result);
}
}

View File

@ -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<String> 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());
}
}

View File

@ -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> 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<LinkPreviewState> getLinkPreviewState() {
return linkPreviewState;
}
public boolean hasLinkPreview() {
return linkPreviewState.getValue() != null && linkPreviewState.getValue().getLinkPreview().isPresent();
}
public @NonNull List<LinkPreview> 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<String> urls = LinkPreviewUtil.findWhitelistedUrls(text);
Optional<String> 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> linkPreview;
private LinkPreviewState(boolean isLoading, Optional<LinkPreview> linkPreview) {
this.isLoading = isLoading;
this.linkPreview = linkPreview;
}
private static LinkPreviewState forLoading() {
return new LinkPreviewState(true, Optional.absent());
}
private static LinkPreviewState forPreview(@NonNull Optional<LinkPreview> linkPreview) {
return new LinkPreviewState(false, linkPreview);
}
private static LinkPreviewState forEmpty() {
return new LinkPreviewState(false, Optional.absent());
}
public boolean isLoading() {
return isLoading;
}
public Optional<LinkPreview> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new LinkPreviewViewModel(repository));
}
}
}

View File

@ -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<Attachment> attachments = new LinkedList<>();
private final List<Contact> sharedContacts = new LinkedList<>();
private final List<Attachment> attachments = new LinkedList<>();
private final List<Contact> sharedContacts = new LinkedList<>();
private final List<LinkPreview> linkPreviews = new LinkedList<>();
public IncomingMediaMessage(Address from,
Optional<Address> groupId,
@ -63,7 +65,8 @@ public class IncomingMediaMessage {
Optional<SignalServiceGroup> group,
Optional<List<SignalServiceAttachment>> attachments,
Optional<QuoteModel> quote,
Optional<List<Contact>> sharedContacts)
Optional<List<Contact>> sharedContacts,
Optional<List<LinkPreview>> 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<LinkPreview> getLinkPreviews() {
return linkPreviews;
}
public boolean isUnidentified() {
return unidentified;
}

View File

@ -11,7 +11,8 @@ public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage
public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn) {
super(recipient, "", new LinkedList<Attachment>(), sentTimeMillis,
ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, null, Collections.emptyList());
ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, null, Collections.emptyList(),
Collections.emptyList());
}
@Override

View File

@ -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<Contact> contacts)
@NonNull List<Contact> contacts,
@NonNull List<LinkPreview> 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<Contact> contacts)
@NonNull List<Contact> contacts,
@NonNull List<LinkPreview> previews)
{
super(recipient, Base64.encodeBytes(group.toByteArray()),
new LinkedList<Attachment>() {{if (avatar != null) add(avatar);}},
System.currentTimeMillis(),
ThreadDatabase.DistributionTypes.CONVERSATION, expireIn, quote, contacts);
ThreadDatabase.DistributionTypes.CONVERSATION, expireIn, quote, contacts, previews);
this.group = group;
}

View File

@ -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<NetworkFailure> networkFailures = new LinkedList<>();
private final List<IdentityKeyMismatch> identityKeyMismatches = new LinkedList<>();
private final List<Contact> contacts = new LinkedList<>();
private final List<LinkPreview> linkPreviews = new LinkedList<>();
public OutgoingMediaMessage(Recipient recipient, String message,
List<Attachment> attachments, long sentTimeMillis,
@ -34,6 +36,7 @@ public class OutgoingMediaMessage {
int distributionType,
@Nullable QuoteModel outgoingQuote,
@NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews,
@NonNull List<NetworkFailure> networkFailures,
@NonNull List<IdentityKeyMismatch> 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<Contact> contacts)
public OutgoingMediaMessage(Recipient recipient, SlideDeck slideDeck, String message,
long sentTimeMillis, int subscriptionId, long expiresIn,
int distributionType, @Nullable QuoteModel outgoingQuote,
@NonNull List<Contact> contacts, @NonNull List<LinkPreview> 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<LinkPreview> getLinkPreviews() {
return linkPreviews;
}
public @NonNull List<NetworkFailure> getNetworkFailures() {
return networkFailures;
}

View File

@ -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<Contact> contacts)
@NonNull List<Contact> contacts,
@NonNull List<LinkPreview> 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) {

View File

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

View File

@ -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<InputStream> getStream() {
while(stream == null && !canceled) {
Util.wait(this, 0);
}
return Optional.fromNullable(this.stream);
}
}

View File

@ -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<ByteRange> requestPattern;
try {
requestPattern = getRequestPattern(contentLength);
} catch (IOException e) {
callback.onFailure(e);
compositeController.cancel();
return;
}
SignalExecutors.IO.execute(() -> {
List<CallRequestController> controllers = Stream.of(requestPattern).map(range -> makeChunkRequest(client, url, range)).toList();
List<InputStream> streams = new ArrayList<>(controllers.size());
Stream.of(controllers).forEach(compositeController::addController);
for (CallRequestController controller : controllers) {
Optional<InputStream> 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<ByteRange> 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<ByteRange> getRequestPattern(long size, long increment) {
List<ByteRange> 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<InputStream> inputStreams;
private int currentStreamIndex = 0;
InputStreamList(List<InputStream> 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<inputStreams.size();i++) {
try {
int available = inputStreams.get(i).available();
if (available != -1) total += available;
} catch (IOException ignored) {}
}
return total;
}
}
public interface Callback {
void onSuccess(InputStream stream) throws IOException;
void onFailure(Exception e);
}
}

View File

@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.net;
import android.support.annotation.NonNull;
import com.annimon.stream.Stream;
import java.util.ArrayList;
import java.util.List;
public class CompositeRequestController implements RequestController {
private final List<RequestController> 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;
}
}

View File

@ -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<String> WHITELISTED_DOMAINS = new HashSet<>();
static {
WHITELISTED_DOMAINS.addAll(LinkPreviewDomains.LINKS);
WHITELISTED_DOMAINS.addAll(LinkPreviewDomains.IMAGES);
WHITELISTED_DOMAINS.add("giphy.com");
}
private final List<Proxy> EMPTY = new ArrayList<>(1);
private volatile List<Proxy> 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<Proxy> 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<Proxy>(1) {{
add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(BuildConfig.CONTENT_PROXY_HOST,
BuildConfig.CONTENT_PROXY_PORT)));
}};
}
private List<Proxy> getOrCreateContentProxy() {
if (CONTENT == null) {
synchronized (this) {
while (CONTENT == null) Util.wait(this, 0);
}
}
return CONTENT;
}
}

View File

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

View File

@ -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 ");

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

@ -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 <E> void run(@NonNull BackgroundTask<E> backgroundTask, @NonNull ForegroundTask<E> 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);
}