Added support for link previews.

This commit is contained in:
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:preference-v14:$supportVersion"
compile "com.android.support:gridlayout-v7:$supportVersion" compile "com.android.support:gridlayout-v7:$supportVersion"
compile "com.android.support:exifinterface:$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 'com.android.support:multidex:1.0.3'
compile 'android.arch.lifecycle:extensions:1.1.1' compile 'android.arch.lifecycle:extensions:1.1.1'
compile 'android.arch.lifecycle:common-java8: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-core:2.9.1'
compile 'com.google.android.exoplayer:exoplayer-ui: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 'org.whispersystems:webrtc-android:M69'
compile "me.leolin:ShortcutBadger:1.1.16" compile "me.leolin:ShortcutBadger:1.1.16"
@ -172,6 +173,7 @@ dependencyVerification {
'com.android.support:cardview-v7:bc9e6b0e06ce1205f1db34f0e6193019613d19cfeb54cdccea722340d1c60f26', 'com.android.support:cardview-v7:bc9e6b0e06ce1205f1db34f0e6193019613d19cfeb54cdccea722340d1c60f26',
'com.android.support:gridlayout-v7:5029529f7db66f8773426bf7318645f0840fc50d74f66355cd60c5e58d2da087', 'com.android.support:gridlayout-v7:5029529f7db66f8773426bf7318645f0840fc50d74f66355cd60c5e58d2da087',
'com.android.support:exifinterface:bbf44e519edd6333a24a3285aa21fd00181b920b81ca8aa89a8899f03ab4d6b0', 'com.android.support:exifinterface:bbf44e519edd6333a24a3285aa21fd00181b920b81ca8aa89a8899f03ab4d6b0',
'com.android.support.constraint:constraint-layout:27b4e5c0b80d3ff8b92f4c93b3b4d3ecf16c01589f4cdf70ca7cf64cb42d8122',
'com.android.support:multidex:ecf6098572e23b5155bab3b9a82b2fd1530eda6c6c157745e0f5287c66eec60c', 'com.android.support:multidex:ecf6098572e23b5155bab3b9a82b2fd1530eda6c6c157745e0f5287c66eec60c',
'android.arch.work:work-runtime:810fba0ee8fc58560664b58c6dba532eae05e3d196e9ee5ae78c1f22bdb292bb', 'android.arch.work:work-runtime:810fba0ee8fc58560664b58c6dba532eae05e3d196e9ee5ae78c1f22bdb292bb',
'android.arch.lifecycle:extensions:429426b2feec2245ffc5e75b3b5309bedb36159cf06dc71843ae43526ac289b6', 'android.arch.lifecycle:extensions:429426b2feec2245ffc5e75b3b5309bedb36159cf06dc71843ae43526ac289b6',
@ -182,7 +184,7 @@ dependencyVerification {
'com.google.android.gms:play-services-auth:aec9e1c584d442cb9f59481a50b2c66dc191872607c04d97ecb82dd0eb5149ec', 'com.google.android.gms:play-services-auth:aec9e1c584d442cb9f59481a50b2c66dc191872607c04d97ecb82dd0eb5149ec',
'com.google.android.exoplayer:exoplayer-ui:7a942afcc402ff01e9bf48e8d3942850986710f06562d50a1408aaf04a683151', 'com.google.android.exoplayer:exoplayer-ui:7a942afcc402ff01e9bf48e8d3942850986710f06562d50a1408aaf04a683151',
'com.google.android.exoplayer:exoplayer-core:b6ab34abac36bc2bc6934b7a50008162feca2c0fde91aaf1e8c1c22f2c16e2c0', 'com.google.android.exoplayer:exoplayer-core:b6ab34abac36bc2bc6934b7a50008162feca2c0fde91aaf1e8c1c22f2c16e2c0',
'org.whispersystems:signal-service-android:d48244f9e19a4300b0baf65c2cef8c76082d55f11d331b00d098c686729cde2e', 'org.whispersystems:signal-service-android:0afd2cb17ed920611dacc54385f3ed375847c10ecd7839a025d9c61c387f7678',
'org.whispersystems:webrtc-android:5493c92141ce884fc5ce8240d783232f4fe14bd17a8d0d7d1bd4944d0bd1682f', 'org.whispersystems:webrtc-android:5493c92141ce884fc5ce8240d783232f4fe14bd17a8d0d7d1bd4944d0bd1682f',
'me.leolin:ShortcutBadger:e3cb3e7625892129b0c92dd5e4bc649faffdd526d5af26d9c45ee31ff8851774', 'me.leolin:ShortcutBadger:e3cb3e7625892129b0c92dd5e4bc649faffdd526d5af26d9c45ee31ff8851774',
'se.emilsjolander:stickylistheaders:a08ca948aa6b220f09d82f16bbbac395f6b78897e9eeac6a9f0b0ba755928eeb', 'se.emilsjolander:stickylistheaders:a08ca948aa6b220f09d82f16bbbac395f6b78897e9eeac6a9f0b0ba755928eeb',
@ -251,9 +253,10 @@ dependencyVerification {
'android.arch.persistence:db-framework:bd665448330acb90a6f551a87b0ba69169da2b8ec168b92f387997339cc14311', 'android.arch.persistence:db-framework:bd665448330acb90a6f551a87b0ba69169da2b8ec168b92f387997339cc14311',
'android.arch.persistence:db:504e8c4307bfd53084924776ba3d49fed11b6f76d82dd80d5121c2d907fdfef6', 'android.arch.persistence:db:504e8c4307bfd53084924776ba3d49fed11b6f76d82dd80d5121c2d907fdfef6',
'com.android.support:support-annotations:5d5b9414f02d3fa0ee7526b8d5ddae0da67c8ecc8c4d63ffa6cf91488a93b927', 'com.android.support:support-annotations:5d5b9414f02d3fa0ee7526b8d5ddae0da67c8ecc8c4d63ffa6cf91488a93b927',
'com.android.support.constraint:constraint-layout-solver:2cafbe356f71c208013d021f32943904798cd6459e5107f9fe27000eb5bc2aef',
'com.google.guava:listenablefuture:e4ad7607e5c0477c6f890ef26a49cb8d1bb4dffb650bab4502afee64644e3069', 'com.google.guava:listenablefuture:e4ad7607e5c0477c6f890ef26a49cb8d1bb4dffb650bab4502afee64644e3069',
'org.signal:signal-metadata-android:d9d798aab7ee7200373ecff8718baf8aaeb632c123604e8a41b7b4c0c97eeee1', '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:disklrucache:c1b1b6f5bbd01e2fcdc9d7f60913c8d338bdb65ed4a93bfa02b56f19daaade4b',
'com.github.bumptech.glide:annotations:bede99ef9f71517a4274bac18fd3e483e9f2b6108d7d6fe8f4949be4aa4d9512', 'com.github.bumptech.glide:annotations:bede99ef9f71517a4274bac18fd3e483e9f2b6108d7d6fe8f4949be4aa4d9512',
'com.nineoldandroids:library:68025a14e3e7673d6ad2f95e4b46d78d7d068343aa99256b686fe59de1b3163a', 'com.nineoldandroids:library:68025a14e3e7673d6ad2f95e4b46d78d7d068343aa99256b686fe59de1b3163a',
@ -306,8 +309,8 @@ android {
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\"" buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\"" buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\"" buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "GIPHY_PROXY_HOST", "\"giphy-proxy-production.whispersystems.org\"" buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "GIPHY_PROXY_PORT", "80" buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "USER_AGENT", "\"OWA\"" buildConfigField "String", "USER_AGENT", "\"OWA\""
buildConfigField "boolean", "DEV_BUILD", "false" buildConfigField "boolean", "DEV_BUILD", "false"
buildConfigField "String", "MRENCLAVE", "\"cd6cfc342937b23b1bdd3bbf9721aa5615ac9ff50a75c5527d441cd3276826c9\"" 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" app:quote_colorSecondary="?attr/conversation_item_sent_text_primary_color"
tools:visibility="visible"/> 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 <FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -128,6 +128,12 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout="@layout/conversation_item_received_thumbnail" /> 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 <ViewStub
android:id="@+id/audio_view_stub" android:id="@+id/audio_view_stub"
android:layout="@layout/conversation_item_received_audio" 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_height="wrap_content"
android:layout="@layout/conversation_item_sent_thumbnail" /> 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 <ViewStub
android:id="@+id/audio_view_stub" android:id="@+id/audio_view_stub"
android:layout="@layout/conversation_item_sent_audio" 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>

108
res/layout/link_preview.xml Normal file
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:id="@+id/quote_dismiss"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="4dp" android:layout_marginEnd="6dp"
android:layout_marginRight="4dp" android:layout_marginRight="6dp"
android:layout_marginTop="4dp" android:layout_marginTop="6dp"
android:layout_gravity="top|end" android:layout_gravity="top|end"
android:background="@drawable/dismiss_background" android:background="@drawable/dismiss_background"
android:src="@drawable/ic_close_white_18dp" android:src="@drawable/ic_close_white_18dp"

View File

@ -110,6 +110,11 @@
<attr name="invite_background" format="color"/> <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="reminder_header_background" format="color"/>
<attr name="menu_new_conversation_icon" format="reference" /> <attr name="menu_new_conversation_icon" format="reference" />
@ -281,6 +286,13 @@
<attr name="contact_footerAlpha" format="float" /> <attr name="contact_footerAlpha" format="float" />
</declare-styleable> </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"> <declare-styleable name="DocumentView">
<attr name="doc_titleColor" format="color" /> <attr name="doc_titleColor" format="color" />
<attr name="doc_captionColor" format="color" /> <attr name="doc_captionColor" format="color" />

View File

@ -51,6 +51,8 @@
<dimen name="mediasend_progress_dialog_size">120dp</dimen> <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_compose_height">40dp</dimen>
<dimen name="conversation_individual_right_gutter">16dp</dimen> <dimen name="conversation_individual_right_gutter">16dp</dimen>
<dimen name="conversation_individual_left_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_turn_on_typing_indicators">Turn on typing indicators</string>
<string name="ExperienceUpgradeActivity_no_thanks">No thanks</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 --> <!-- GcmBroadcastReceiver -->
<string name="GcmBroadcastReceiver_retrieving_a_message">Retrieving a message...</string> <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__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__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__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_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__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> <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_category_emoticons">@drawable/emoji_category_emoticons_light</item>
<item name="emoji_variation_selector_background">@drawable/emoji_variation_selector_background_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_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_primary_color">@color/core_grey_90</item>
<item name="conversation_item_sent_text_secondary_color">@color/core_grey_60</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_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="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_new_conversation_icon">@drawable/ic_add_white_24dp</item>
<item name="menu_group_icon">@drawable/ic_group_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> <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_category_emoticons">@drawable/emoji_category_emoticons_dark</item>
<item name="emoji_variation_selector_background">@drawable/emoji_variation_selector_background_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_camera_icon">@drawable/quick_camera_dark</item>
<item name="quick_mic_icon">@drawable/ic_mic_white_24dp</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:title="@string/preferences__typing_indicators"
android:summary="@string/preferences__if_typing_indicators_are_disabled_you_wont_be_able_to_see_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" <Preference android:key="preference_category_blocked"
android:title="@string/preferences_app_protection__blocked_contacts" /> android:title="@string/preferences_app_protection__blocked_contacts" />
</PreferenceCategory> </PreferenceCategory>

View File

@ -56,6 +56,7 @@
android:key="pref_enter_sends" android:key="pref_enter_sends"
android:summary="@string/preferences__pressing_the_enter_key_will_send_text_messages" android:summary="@string/preferences__pressing_the_enter_key_will_send_text_messages"
android:title="@string/preferences__pref_enter_sends_title"/> android:title="@string/preferences__pref_enter_sends_title"/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:layout="@layout/preference_divider"/> <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.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.PRNGFixes; 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.AxolotlStorageModule;
import org.thoughtcrime.securesms.dependencies.InjectableType; import org.thoughtcrime.securesms.dependencies.InjectableType;
import org.thoughtcrime.securesms.dependencies.SignalCommunicationModule; 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.contactshare.Contact;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
@ -31,6 +32,7 @@ public interface BindableConversationItem extends Unbindable {
interface EventListener { interface EventListener {
void onQuoteClicked(MmsMessageRecord messageRecord); void onQuoteClicked(MmsMessageRecord messageRecord);
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView); void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView);
void onAddToContactsClicked(@NonNull Contact contact); void onAddToContactsClicked(@NonNull Contact contact);
void onMessageSharedContactClicked(@NonNull List<Recipient> choices); void onMessageSharedContactClicked(@NonNull List<Recipient> choices);

View File

@ -19,6 +19,7 @@ package org.thoughtcrime.securesms;
import android.Manifest; import android.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.annotation.TargetApi; import android.annotation.TargetApi;
import android.arch.lifecycle.ViewModelProviders;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.Context; 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.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent; import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity; 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.MediaSendActivity;
import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob;
@ -270,6 +274,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
protected HidingLinearLayout inlineAttachmentToggle; protected HidingLinearLayout inlineAttachmentToggle;
private QuickAttachmentDrawer quickAttachmentDrawer; private QuickAttachmentDrawer quickAttachmentDrawer;
private InputPanel inputPanel; private InputPanel inputPanel;
private LinkPreviewViewModel linkPreviewViewModel;
private Recipient recipient; private Recipient recipient;
private long threadId; private long threadId;
@ -309,6 +314,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
initializeActionBar(); initializeActionBar();
initializeViews(); initializeViews();
initializeResources(); initializeResources();
initializeLinkPreviewObserver();
initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() { initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
@Override @Override
public void onSuccess(Boolean result) { public void onSuccess(Boolean result) {
@ -443,6 +449,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
if ((data == null && reqCode != TAKE_PHOTO && reqCode != SMS_DEFAULT) || if ((data == null && reqCode != TAKE_PHOTO && reqCode != SMS_DEFAULT) ||
(resultCode != RESULT_OK && reqCode != SMS_DEFAULT)) (resultCode != RESULT_OK && reqCode != SMS_DEFAULT))
{ {
updateLinkPreviewState();
return; return;
} }
@ -516,7 +523,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
slideDeck.addSlide(new ImageSlide(this, data.getData(), imageSize, imageWidth, imageHeight)); 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; break;
case MEDIA_SENDER: 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; break;
} }
@ -1438,6 +1445,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
sendButton.setEnabled(true); sendButton.setEnabled(true);
sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> { sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> {
calculateCharactersRemaining(); calculateCharactersRemaining();
updateLinkPreviewState();
composeText.setTransport(newTransport); composeText.setTransport(newTransport);
buttonToggle.getBackground().setColorFilter(newTransport.getBackgroundColor(), Mode.MULTIPLY); buttonToggle.getBackground().setColorFilter(newTransport.getBackgroundColor(), Mode.MULTIPLY);
buttonToggle.getBackground().invalidateSelf(); buttonToggle.getBackground().invalidateSelf();
@ -1496,6 +1504,31 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
recipient.addListener(this); 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() { private void initializeProfiles() {
if (!isSecureText) { if (!isSecureText) {
Log.i(TAG, "SMS contact, no profile fetch needed."); Log.i(TAG, "SMS contact, no profile fetch needed.");
@ -1546,6 +1579,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
//////// Helper Methods //////// Helper Methods
private void addAttachment(int type) { private void addAttachment(int type) {
linkPreviewViewModel.onUserCancel();
Log.i(TAG, "Selected: " + type); Log.i(TAG, "Selected: " + type);
switch (type) { switch (type) {
case AttachmentTypeSelector.ADD_GALLERY: case AttachmentTypeSelector.ADD_GALLERY:
@ -1604,7 +1639,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
long expiresIn = recipient.getExpireMessages() * 1000L; long expiresIn = recipient.getExpireMessages() * 1000L;
boolean initiating = threadId == -1; 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) { private void selectContactInfo(ContactData contactData) {
@ -1843,6 +1878,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
fragment.scrollToBottom(); fragment.scrollToBottom();
attachmentManager.cleanup(); attachmentManager.cleanup();
updateLinkPreviewState();
} }
private void sendMessage() { private void sendMessage() {
@ -1857,6 +1894,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
long expiresIn = recipient.getExpireMessages() * 1000L; long expiresIn = recipient.getExpireMessages() * 1000L;
boolean initiating = threadId == -1; 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, "isManual Selection: " + sendButton.isManualSelection());
Log.i(TAG, "forceSms: " + forceSms); Log.i(TAG, "forceSms: " + forceSms);
@ -1867,7 +1909,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
handleUnverifiedRecipients(); handleUnverifiedRecipients();
} else if (!forceSms && identityRecords.isUntrusted()) { } else if (!forceSms && identityRecords.isUntrusted()) {
handleUntrustedRecipients(); handleUntrustedRecipients();
} else if (attachmentManager.isAttachmentPresent() || recipient.isGroupRecipient() || recipient.getAddress().isEmail() || inputPanel.getQuote().isPresent()) { } else if (isMediaMessage) {
sendMediaMessage(forceSms, expiresIn, subscriptionId, initiating); sendMediaMessage(forceSms, expiresIn, subscriptionId, initiating);
} else { } else {
sendTextMessage(forceSms, expiresIn, subscriptionId, initiating); sendTextMessage(forceSms, expiresIn, subscriptionId, initiating);
@ -1888,16 +1930,24 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
throws InvalidMessageException throws InvalidMessageException
{ {
Log.i(TAG, "Sending media message..."); 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)) { if (!isDefaultSms && (!isSecureText || forceSms)) {
showDefaultSmsPrompt(); showDefaultSmsPrompt();
return new SettableFuture<>(null); 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 SettableFuture<Void> future = new SettableFuture<>();
final Context context = getApplicationContext(); final Context context = getApplicationContext();
@ -2009,7 +2059,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
buttonToggle.display(sendButton); buttonToggle.display(sendButton);
quickAttachmentToggle.hide(); quickAttachmentToggle.hide();
if (!attachmentManager.isAttachmentPresent()) { if (!attachmentManager.isAttachmentPresent() && !linkPreviewViewModel.hasLinkPreview()) {
inlineAttachmentToggle.show(); inlineAttachmentToggle.show();
} else { } else {
inlineAttachmentToggle.hide(); 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) { private void recordSubscriptionIdPreference(final Optional<Integer> subscriptionId) {
new AsyncTask<Void, Void, Void>() { new AsyncTask<Void, Void, Void>() {
@Override @Override
@ -2104,7 +2163,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
SlideDeck slideDeck = new SlideDeck(); SlideDeck slideDeck = new SlideDeck();
slideDeck.addSlide(audioSlide); 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 @Override
public void onSuccess(Void nothing) { public void onSuccess(Void nothing) {
new AsyncTask<Void, Void, Void>() { new AsyncTask<Void, Void, Void>() {
@ -2164,6 +2223,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
} }
} }
@Override
public void onLinkPreviewCanceled() {
linkPreviewViewModel.onUserCancel();
}
@Override @Override
public void onMediaSelected(@NonNull Uri uri, String contentType) { public void onMediaSelected(@NonNull Uri uri, String contentType) {
if (!TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif")) { if (!TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif")) {
@ -2193,6 +2257,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
@Override @Override
public void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height) { 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()); 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); 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) { public void afterTextChanged(Editable s) {
calculateCharactersRemaining(); 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); composeText.postDelayed(ConversationActivity.this::updateToggleButtonState, 50);
} }
} }
@ -2336,6 +2405,21 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
author, author,
body, body,
slideDeck); 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 { } else {
inputPanel.setQuote(GlideApp.with(this), inputPanel.setQuote(GlideApp.with(this),
messageRecord.getDateSent(), messageRecord.getDateSent(),
@ -2349,6 +2433,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
public void onAttachmentChanged() { public void onAttachmentChanged() {
handleSecurityChange(isSecureText, isDefaultSms); handleSecurityChange(isSecureText, isDefaultSms);
updateToggleButtonState(); updateToggleButtonState();
updateLinkPreviewState();
} }
private class UnverifiedDismissedListener implements UnverifiedBannerView.DismissListener { 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.attachments.Attachment;
import org.thoughtcrime.securesms.components.ConversationTypingView; import org.thoughtcrime.securesms.components.ConversationTypingView;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
@ -879,6 +880,13 @@ public class ConversationFragment extends Fragment
}.execute(); }.execute();
} }
@Override
public void onLinkPreviewClicked(@NonNull LinkPreview linkPreview) {
if (getContext() != null && getActivity() != null) {
CommunicationActions.openBrowserLink(getActivity(), linkPreview.getUrl());
}
}
@Override @Override
public void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView) { public void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView) {
if (getContext() != null && getActivity() != null) { if (getContext() != null && getActivity() != null) {

View File

@ -34,6 +34,10 @@ import android.text.TextUtils;
import android.text.style.URLSpan; import android.text.style.URLSpan;
import android.text.util.Linkify; import android.text.util.Linkify;
import android.util.AttributeSet; 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 org.thoughtcrime.securesms.logging.Log;
import android.util.TypedValue; import android.util.TypedValue;
import android.view.View; 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.MmsSendJob;
import org.thoughtcrime.securesms.jobs.SmsSendJob; import org.thoughtcrime.securesms.jobs.SmsSendJob;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlideClickListener;
@ -127,6 +132,7 @@ public class ConversationItem extends LinearLayout
private @NonNull Stub<AudioView> audioViewStub; private @NonNull Stub<AudioView> audioViewStub;
private @NonNull Stub<DocumentView> documentViewStub; private @NonNull Stub<DocumentView> documentViewStub;
private @NonNull Stub<SharedContactView> sharedContactStub; private @NonNull Stub<SharedContactView> sharedContactStub;
private @NonNull Stub<LinkPreviewView> linkPreviewStub;
private @Nullable EventListener eventListener; private @Nullable EventListener eventListener;
private int defaultBubbleColor; private int defaultBubbleColor;
@ -137,6 +143,7 @@ public class ConversationItem extends LinearLayout
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener); private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener(); private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener(); private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
private final Context context; private final Context context;
@ -172,6 +179,7 @@ public class ConversationItem extends LinearLayout
this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub)); this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub));
this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub)); this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub));
this.sharedContactStub = new Stub<>(findViewById(R.id.shared_contact_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.groupSenderHolder = findViewById(R.id.group_sender_holder);
this.quoteView = findViewById(R.id.quote_view); this.quoteView = findViewById(R.id.quote_view);
this.container = findViewById(R.id.container); this.container = findViewById(R.id.container);
@ -383,6 +391,10 @@ public class ConversationItem extends LinearLayout
return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getSharedContacts().isEmpty(); return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getSharedContacts().isEmpty();
} }
private boolean hasLinkPreview(MessageRecord messageRecord) {
return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getLinkPreviews().isEmpty();
}
private void setBodyText(MessageRecord messageRecord) { private void setBodyText(MessageRecord messageRecord) {
bodyText.setClickable(false); bodyText.setClickable(false);
bodyText.setFocusable(false); bodyText.setFocusable(false);
@ -409,6 +421,7 @@ public class ConversationItem extends LinearLayout
if (audioViewStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (audioViewStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.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().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
sharedContactStub.get().setEventListener(sharedContactEventListener); sharedContactStub.get().setEventListener(sharedContactEventListener);
@ -418,13 +431,51 @@ public class ConversationItem extends LinearLayout
setSharedContactCorners(messageRecord, previousRecord, nextRecord, isGroupThread); setSharedContactCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); 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); 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)) { } else if (hasAudio(messageRecord)) {
audioViewStub.get().setVisibility(View.VISIBLE); audioViewStub.get().setVisibility(View.VISIBLE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
//noinspection ConstantConditions //noinspection ConstantConditions
audioViewStub.get().setAudio(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide(), showControls); 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 (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
//noinspection ConstantConditions //noinspection ConstantConditions
documentViewStub.get().setDocument(((MediaMmsMessageRecord)messageRecord).getSlideDeck().getDocumentSlide(), showControls); documentViewStub.get().setDocument(((MediaMmsMessageRecord)messageRecord).getSlideDeck().getDocumentSlide(), showControls);
@ -451,9 +503,10 @@ public class ConversationItem extends LinearLayout
footer.setVisibility(VISIBLE); footer.setVisibility(VISIBLE);
} else if (hasThumbnail(messageRecord)) { } else if (hasThumbnail(messageRecord)) {
mediaThumbnailStub.get().setVisibility(View.VISIBLE); mediaThumbnailStub.get().setVisibility(View.VISIBLE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
//noinspection ConstantConditions //noinspection ConstantConditions
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides(); List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
@ -469,7 +522,7 @@ public class ConversationItem extends LinearLayout
mediaThumbnailStub.get().setConversationColor(messageRecord.isOutgoing() ? defaultBubbleColor mediaThumbnailStub.get().setConversationColor(messageRecord.isOutgoing() ? defaultBubbleColor
: messageRecord.getRecipient().getColor().toConversationColor(context)); : 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(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, 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 (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(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(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, 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, private void setThumbnailCorners(@NonNull MessageRecord current,
@NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> previous,
@NonNull Optional<MessageRecord> next, @NonNull Optional<MessageRecord> next,
boolean isGroupThread) boolean isGroupThread)
{ {
int defaultRadius = readDimen(R.dimen.message_corner_radius); int defaultRadius = readDimen(R.dimen.message_corner_radius);
int collapseRadius = readDimen(R.dimen.message_corner_collapse_radius); int collapseRadius = readDimen(R.dimen.message_corner_collapse_radius);
@ -541,18 +595,38 @@ public class ConversationItem extends LinearLayout
topRight = 0; 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) { 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)) { if (isSingularMessage(current, previous, next, isGroupThread) || isEndOfMessageCluster(current, next, isGroupThread)) {
sharedContactStub.get().setSingularStyle(); sharedContactStub.get().setSingularStyle();
} else if (current.isOutgoing()) {
sharedContactStub.get().setClusteredOutgoingStyle();
} else { } else {
if (current.isOutgoing()) { sharedContactStub.get().setClusteredIncomingStyle();
sharedContactStub.get().setClusteredOutgoingStyle(); }
} else { }
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); 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) { private SpannableString linkifyMessageBody(SpannableString messageBody, boolean shouldLinkifyAllLinks) {
int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS; int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS;
boolean hasLinks = Linkify.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0); 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 { private class AttachmentDownloadClickListener implements SlidesClickedListener {
@Override @Override
public void onClick(View v, final List<Slide> slides) { 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.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.lock.RegistrationLockDialog; import org.thoughtcrime.securesms.lock.RegistrationLockDialog;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier; 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.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.TextSecurePreferences; 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 org.whispersystems.libsignal.util.guava.Optional;
import java.util.List; import java.util.List;
@ -112,7 +111,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
dynamicTheme.onResume(this); dynamicTheme.onResume(this);
dynamicLanguage.onResume(this); dynamicLanguage.onResume(this);
LifecycleBoundTask.run(getLifecycle(), () -> { SimpleTask.run(getLifecycle(), () -> {
return Recipient.from(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)), false); return Recipient.from(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)), false);
}, this::initializeProfileIcon); }, this::initializeProfileIcon);
} }

View File

@ -29,7 +29,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections; import java.util.Collections;
import java.util.List; 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 TAG = ExperienceUpgradeActivity.class.getSimpleName();
private static final String DISMISS_ACTION = "org.thoughtcrime.securesms.ExperienceUpgradeActivity.DISMISS_ACTION"; private static final String DISMISS_ACTION = "org.thoughtcrime.securesms.ExperienceUpgradeActivity.DISMISS_ACTION";
private static final int NOTIFICATION_ID = 1339; 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,
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, 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 int version;
private List<IntroPage> pages; private List<IntroPage> pages;
@ -215,10 +222,15 @@ public class ExperienceUpgradeActivity extends BaseActionBarActivity implements
} }
@Override @Override
public void onFinished() { public void onTypingIndicatorsFinished() {
onContinue(Optional.of(ExperienceUpgrade.TYPING_INDICATORS)); 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 class OnPageChangeListener implements ViewPager.OnPageChangeListener {
private final ArgbEvaluator evaluator = new ArgbEvaluator(); private final ArgbEvaluator evaluator = new ArgbEvaluator();
private final ExperienceUpgrade upgrade; 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(), .add(new MultiDeviceConfigurationUpdateJob(getContext(),
isChecked, isChecked,
TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()),
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()))); TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
}); });
return v; return v;

View File

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

View File

@ -4,7 +4,6 @@ package org.thoughtcrime.securesms;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.support.v4.app.Fragment; import android.support.v4.app.Fragment;
import android.support.v7.widget.SwitchCompat;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -12,7 +11,6 @@ import android.view.ViewGroup;
import org.thoughtcrime.securesms.components.TypingIndicatorView; import org.thoughtcrime.securesms.components.TypingIndicatorView;
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
public class TypingIndicatorIntroFragment extends Fragment { public class TypingIndicatorIntroFragment extends Fragment {
@ -64,12 +62,13 @@ public class TypingIndicatorIntroFragment extends Fragment {
.add(new MultiDeviceConfigurationUpdateJob(getContext(), .add(new MultiDeviceConfigurationUpdateJob(getContext(),
TextSecurePreferences.isReadReceiptsEnabled(requireContext()), TextSecurePreferences.isReadReceiptsEnabled(requireContext()),
typingEnabled, typingEnabled,
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()))); TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
controller.onFinished(); controller.onTypingIndicatorsFinished();
} }
public interface Controller { public interface Controller {
void onFinished(); void onTypingIndicatorsFinished();
} }
} }

View File

@ -26,24 +26,12 @@ import java.util.List;
public class ConversationItemThumbnail extends FrameLayout { 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 ThumbnailView thumbnail;
private AlbumThumbnailView album; private AlbumThumbnailView album;
private ImageView shade; private ImageView shade;
private ConversationItemFooter footer; private ConversationItemFooter footer;
private CornerMask cornerMask; private CornerMask cornerMask;
private Outliner outliner;
private final Paint outlinePaint = new Paint();
{
outlinePaint.setStyle(Paint.Style.STROKE);
outlinePaint.setStrokeWidth(1f);
outlinePaint.setAntiAlias(true);
}
public ConversationItemThumbnail(Context context) { public ConversationItemThumbnail(Context context) {
super(context); super(context);
@ -63,13 +51,14 @@ public class ConversationItemThumbnail extends FrameLayout {
private void init(@Nullable AttributeSet attrs) { private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.conversation_item_thumbnail, this); 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.thumbnail = findViewById(R.id.conversation_thumbnail_image);
this.album = findViewById(R.id.conversation_thumbnail_album); this.album = findViewById(R.id.conversation_thumbnail_album);
this.shade = findViewById(R.id.conversation_thumbnail_shade); this.shade = findViewById(R.id.conversation_thumbnail_shade);
this.footer = findViewById(R.id.conversation_thumbnail_footer); this.footer = findViewById(R.id.conversation_thumbnail_footer);
this.cornerMask = new CornerMask(this); this.cornerMask = new CornerMask(this);
this.outliner = new Outliner();
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
if (attrs != null) { if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0); TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0);
@ -95,17 +84,7 @@ public class ConversationItemThumbnail extends FrameLayout {
} }
if (album.getVisibility() != VISIBLE) { if (album.getVisibility() != VISIBLE) {
final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2; outliner.draw(canvas);
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);
} }
} }
@ -132,13 +111,9 @@ public class ConversationItemThumbnail extends FrameLayout {
forceLayout(); forceLayout();
} }
public void setOutlineCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) { public void setCorners(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;
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft); cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
} }
public ConversationItemFooter getFooter() { public ConversationItemFooter getFooter() {

View File

@ -4,6 +4,7 @@ import android.annotation.TargetApi;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.support.annotation.DimenRes;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v4.view.ViewCompat; import android.support.v4.view.ViewCompat;
@ -22,6 +23,7 @@ import android.widget.Toast;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiDrawer; import org.thoughtcrime.securesms.components.emoji.EmojiDrawer;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle; import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel; import org.thoughtcrime.securesms.mms.QuoteModel;
@ -48,13 +50,14 @@ public class InputPanel extends LinearLayout
private static final int FADE_TIME = 150; private static final int FADE_TIME = 150;
private QuoteView quoteView; private QuoteView quoteView;
private EmojiToggle emojiToggle; private LinkPreviewView linkPreview;
private ComposeText composeText; private EmojiToggle emojiToggle;
private View quickCameraToggle; private ComposeText composeText;
private View quickAudioToggle; private View quickCameraToggle;
private View buttonToggle; private View quickAudioToggle;
private View recordingContainer; private View buttonToggle;
private View recordingContainer;
private MicrophoneRecorderView microphoneRecorderView; private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel; private SlideToCancel slideToCancel;
@ -83,6 +86,7 @@ public class InputPanel extends LinearLayout
View quoteDismiss = findViewById(R.id.quote_dismiss); View quoteDismiss = findViewById(R.id.quote_dismiss);
this.quoteView = findViewById(R.id.quote_view); this.quoteView = findViewById(R.id.quote_view);
this.linkPreview = findViewById(R.id.link_preview);
this.emojiToggle = findViewById(R.id.emoji_toggle); this.emojiToggle = findViewById(R.id.emoji_toggle);
this.composeText = findViewById(R.id.embedded_text_editor); this.composeText = findViewById(R.id.embedded_text_editor);
this.quickCameraToggle = findViewById(R.id.quick_camera_toggle); this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
@ -108,6 +112,12 @@ public class InputPanel extends LinearLayout
} }
quoteDismiss.setOnClickListener(v -> clearQuote()); quoteDismiss.setOnClickListener(v -> clearQuote());
linkPreview.setCloseClickedListener(() -> {
if (listener != null) {
listener.onLinkPreviewCanceled();
}
});
} }
public void setListener(final @NonNull Listener listener) { 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) { 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.setQuote(glideRequests, id, author, body, false, attachments);
this.quoteView.setVisibility(View.VISIBLE); 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() { public void clearQuote() {
this.quoteView.dismiss(); 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() { 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) { public void setEmojiDrawer(@NonNull EmojiDrawer emojiDrawer) {
emojiToggle.attach(emojiDrawer); emojiToggle.attach(emojiDrawer);
} }
@ -238,6 +277,10 @@ public class InputPanel extends LinearLayout
composeText.insertEmoji(emoji); composeText.insertEmoji(emoji);
} }
private int readDimen(@DimenRes int dimenRes) {
return getResources().getDimensionPixelSize(dimenRes);
}
public interface Listener { public interface Listener {
void onRecorderStarted(); void onRecorderStarted();
@ -245,6 +288,7 @@ public class InputPanel extends LinearLayout
void onRecorderCanceled(); void onRecorderCanceled();
void onRecorderPermissionRequired(); void onRecorderPermissionRequired();
void onEmojiToggle(); void onEmojiToggle();
void onLinkPreviewCanceled();
} }
private static class SlideToCancel { 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[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0);
bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0); bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0);
bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 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(); typedArray.recycle();
} else { } else {
radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius); radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
} }
} }
@Override @Override
@ -329,10 +328,18 @@ public class ThumbnailView extends FrameLayout {
slide = null; slide = null;
} }
public void showDownloadText(boolean showDownloadText) {
getTransferControls().setShowDownloadText(showDownloadText);
}
public void showProgressSpinner() { public void showProgressSpinner() {
getTransferControls().showProgressSpinner(); getTransferControls().showProgressSpinner();
} }
protected void setRadius(int radius) {
this.radius = radius;
}
private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri())) GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri()))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) .diskCacheStrategy(DiskCacheStrategy.RESOURCE)

View File

@ -170,6 +170,7 @@ public class TransferControlView extends FrameLayout {
public void setShowDownloadText(boolean showDownloadText) { public void setShowDownloadText(boolean showDownloadText) {
downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE); downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE);
forceLayout();
} }
private boolean isUpdateToExistingSet(@NonNull List<Slide> slides) { private boolean isUpdateToExistingSet(@NonNull List<Slide> slides) {

View File

@ -144,6 +144,10 @@ public class DatabaseFactory {
getInstance(context).databaseHelper.markCurrent(database); 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) { private DatabaseFactory(@NonNull Context context) {
SQLiteDatabase.loadLibs(context); SQLiteDatabase.loadLibs(context);

View File

@ -25,6 +25,7 @@ import android.support.annotation.Nullable;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Pair; import android.util.Pair;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import com.google.android.mms.pdu_alt.NotificationInd; import com.google.android.mms.pdu_alt.NotificationInd;
import com.google.android.mms.pdu_alt.PduHeaders; 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.database.model.Quote;
import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.TrimThreadJob; import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.IncomingMediaMessage; import org.thoughtcrime.securesms.mms.IncomingMediaMessage;
import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.MmsException;
@ -80,7 +82,6 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
import static org.thoughtcrime.securesms.contactshare.Contact.Avatar; import static org.thoughtcrime.securesms.contactshare.Contact.Avatar;
import static org.thoughtcrime.securesms.contactshare.Contact.deserialize;
public class MmsDatabase extends MessagingDatabase { 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_ATTACHMENT = "quote_attachment";
static final String QUOTE_MISSING = "quote_missing"; 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, " + 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, " + 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, " + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " INTEGER DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + QUOTE_ID + " 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_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 = { public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", "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, MESSAGE_SIZE, STATUS, TRANSACTION_ID,
BODY, PART_COUNT, ADDRESS, ADDRESS_DEVICE_ID, BODY, PART_COUNT, ADDRESS, ADDRESS_DEVICE_ID,
DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_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(" + "json_group_array(json_object(" +
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_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 mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES));
String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE)); String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE));
long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)); long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID));
String quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)); String quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR));
String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)); String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY));
boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1; boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1;
List<Attachment> quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList(); List<Attachment> quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList();
List<Contact> contacts = getSharedContacts(cursor, associatedAttachments); List<Contact> contacts = getSharedContacts(cursor, associatedAttachments);
Set<Attachment> contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList()); 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(); 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); Recipient recipient = Recipient.from(context, Address.fromSerialized(address), false);
List<NetworkFailure> networkFailures = new LinkedList<>(); List<NetworkFailure> networkFailures = new LinkedList<>();
@ -623,12 +632,12 @@ public class MmsDatabase extends MessagingDatabase {
} }
if (body != null && (Types.isGroupQuit(outboxType) || Types.isGroupUpdate(outboxType))) { 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)) { } else if (Types.isExpirationTimerUpdate(outboxType)) {
return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn); 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)) { if (Types.isSecureType(outboxType)) {
return new OutgoingSecureMediaMessage(message); return new OutgoingSecureMediaMessage(message);
@ -663,7 +672,7 @@ public class MmsDatabase extends MessagingDatabase {
JSONArray jsonContacts = new JSONArray(serializedContacts); JSONArray jsonContacts = new JSONArray(serializedContacts);
for (int i = 0; i < jsonContacts.length(); i++) { 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) { if (contact.getAvatar() != null && contact.getAvatar().getAttachmentId() != null) {
DatabaseAttachment attachment = attachmentIdMap.get(contact.getAvatar().getAttachmentId()); DatabaseAttachment attachment = attachmentIdMap.get(contact.getAvatar().getAttachmentId());
@ -684,6 +693,43 @@ public class MmsDatabase extends MessagingDatabase {
return Collections.emptyList(); 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 { public long copyMessageInbox(long messageId) throws MmsException {
try { try {
OutgoingMediaMessage request = getOutgoingMessage(messageId); OutgoingMediaMessage request = getOutgoingMessage(messageId);
@ -724,6 +770,7 @@ public class MmsDatabase extends MessagingDatabase {
attachments, attachments,
new LinkedList<>(), new LinkedList<>(),
request.getSharedContacts(), request.getSharedContacts(),
request.getLinkPreviews(),
contentValues, contentValues,
null); null);
} catch (NoSuchMessageException e) { } catch (NoSuchMessageException e) {
@ -783,7 +830,7 @@ public class MmsDatabase extends MessagingDatabase {
return Optional.absent(); 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)) { if (!Types.isExpirationTimerUpdate(mailbox)) {
DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1);
@ -922,7 +969,7 @@ public class MmsDatabase extends MessagingDatabase {
quoteAttachments.addAll(message.getOutgoingQuote().getAttachments()); 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()) { if (message.getRecipient().getAddress().isGroup()) {
List<Recipient> members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(message.getRecipient().getAddress().toGroupString(), false); 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> attachments,
@NonNull List<Attachment> quoteAttachments, @NonNull List<Attachment> quoteAttachments,
@NonNull List<Contact> sharedContacts, @NonNull List<Contact> sharedContacts,
@NonNull List<LinkPreview> linkPreviews,
@NonNull ContentValues contentValues, @NonNull ContentValues contentValues,
@Nullable SmsDatabase.InsertListener insertListener) @Nullable SmsDatabase.InsertListener insertListener)
throws MmsException throws MmsException
@ -955,9 +1003,11 @@ public class MmsDatabase extends MessagingDatabase {
List<Attachment> allAttachments = new LinkedList<>(); List<Attachment> allAttachments = new LinkedList<>();
List<Attachment> contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList(); 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(attachments);
allAttachments.addAll(contactAttachments); allAttachments.addAll(contactAttachments);
allAttachments.addAll(previewAttachments);
contentValues.put(BODY, body); contentValues.put(BODY, body);
contentValues.put(PART_COUNT, allAttachments.size()); contentValues.put(PART_COUNT, allAttachments.size());
@ -967,7 +1017,8 @@ public class MmsDatabase extends MessagingDatabase {
long messageId = db.insert(TABLE_NAME, null, contentValues); long messageId = db.insert(TABLE_NAME, null, contentValues);
Map<Attachment, AttachmentId> insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments); 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)) { if (!TextUtils.isEmpty(serializedContacts)) {
ContentValues contactValues = new ContentValues(); 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(); db.setTransactionSuccessful();
return messageId; return messageId;
} finally { } finally {
@ -1016,7 +1079,7 @@ public class MmsDatabase extends MessagingDatabase {
deleteThreads(singleThreadSet); 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; if (contacts.isEmpty()) return null;
JSONArray sharedContactJson = new JSONArray(); JSONArray sharedContactJson = new JSONArray();
@ -1042,6 +1105,28 @@ public class MmsDatabase extends MessagingDatabase {
return sharedContactJson.toString(); 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) { private boolean isDuplicate(IncomingMediaMessage message, long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", 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(), message.getOutgoingQuote().isOriginalMissing(),
new SlideDeck(context, message.getOutgoingQuote().getAttachments())) : new SlideDeck(context, message.getOutgoingQuote().getAttachments())) :
null, null,
message.getSharedContacts(), false); message.getSharedContacts(), message.getLinkPreviews(), false);
} }
} }
@ -1322,15 +1407,17 @@ public class MmsDatabase extends MessagingDatabase {
List<NetworkFailure> networkFailures = getFailures(networkDocument); List<NetworkFailure> networkFailures = getFailures(networkDocument);
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor); List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor);
List<Contact> contacts = getSharedContacts(cursor, attachments); List<Contact> contacts = getSharedContacts(cursor, attachments);
Set<Attachment> contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList()); Set<Attachment> contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).collect(Collectors.toSet());
SlideDeck slideDeck = getSlideDeck(Stream.of(attachments).filterNot(contactAttachments::contains).toList()); 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); Quote quote = getQuote(cursor);
return new MediaMmsMessageRecord(context, id, recipient, recipient, return new MediaMmsMessageRecord(context, id, recipient, recipient,
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, addressDeviceId, dateSent, dateReceived, deliveryReceiptCount,
threadId, body, slideDeck, partCount, box, mismatches, threadId, body, slideDeck, partCount, box, mismatches,
networkFailures, subscriptionId, expiresIn, expireStarted, networkFailures, subscriptionId, expiresIn, expireStarted,
readReceiptCount, quote, contacts, unidentified); readReceiptCount, quote, contacts, previews, unidentified);
} }
private Recipient getRecipientFor(String serialized) { private Recipient getRecipientFor(String serialized) {

View File

@ -20,7 +20,6 @@ import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.logging.Log;
import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteQueryBuilder; import net.sqlcipher.database.SQLiteQueryBuilder;
@ -70,7 +69,8 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.SHARED_CONTACTS}; MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS};
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper); super(context, databaseHelper);
@ -246,7 +246,8 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.SHARED_CONTACTS}; MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS};
String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
@ -271,7 +272,8 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.SHARED_CONTACTS}; MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS};
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
@ -338,6 +340,7 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING); mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING);
mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT); mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT);
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
Set<String> smsColumnsPresent = new HashSet<>(); Set<String> smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID); 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 SECRET_SENDER = 13;
private static final int ATTACHMENT_CAPTIONS = 14; private static final int ATTACHMENT_CAPTIONS = 14;
private static final int ATTACHMENT_CAPTIONS_FIX = 15; 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 static final String DATABASE_NAME = "signal.db";
private final Context context; 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(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); 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.SmsDatabase.Status;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -56,11 +57,12 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
List<NetworkFailure> failures, int subscriptionId, List<NetworkFailure> failures, int subscriptionId,
long expiresIn, long expireStarted, int readReceiptCount, long expiresIn, long expireStarted, int readReceiptCount,
@Nullable Quote quote, @Nullable List<Contact> contacts, @Nullable Quote quote, @Nullable List<Contact> contacts,
boolean unidentified) @Nullable List<LinkPreview> linkPreviews, boolean unidentified)
{ {
super(context, id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, super(context, id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, 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.context = context.getApplicationContext();
this.partCount = partCount; this.partCount = partCount;

View File

@ -8,6 +8,7 @@ import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -17,9 +18,10 @@ import java.util.List;
public abstract class MmsMessageRecord extends MessageRecord { public abstract class MmsMessageRecord extends MessageRecord {
private final @NonNull SlideDeck slideDeck; private final @NonNull SlideDeck slideDeck;
private final @Nullable Quote quote; private final @Nullable Quote quote;
private final @NonNull List<Contact> contacts = new LinkedList<>(); 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, MmsMessageRecord(Context context, long id, String body, Recipient conversationRecipient,
Recipient individualRecipient, int recipientDeviceId, long dateSent, Recipient individualRecipient, int recipientDeviceId, long dateSent,
@ -27,7 +29,8 @@ public abstract class MmsMessageRecord extends MessageRecord {
long type, List<IdentityKeyMismatch> mismatches, long type, List<IdentityKeyMismatch> mismatches,
List<NetworkFailure> networkFailures, int subscriptionId, long expiresIn, List<NetworkFailure> networkFailures, int subscriptionId, long expiresIn,
long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, 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); 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.quote = quote;
this.contacts.addAll(contacts); this.contacts.addAll(contacts);
this.linkPreviews.addAll(linkPreviews);
} }
@Override @Override
@ -69,4 +73,8 @@ public abstract class MmsMessageRecord extends MessageRecord {
public @NonNull List<Contact> getSharedContacts() { public @NonNull List<Contact> getSharedContacts() {
return contacts; 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, super(context, id, "", conversationRecipient, individualRecipient, recipientDeviceId,
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
new LinkedList<IdentityKeyMismatch>(), new LinkedList<NetworkFailure>(), subscriptionId, 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.contentLocation = contentLocation;
this.messageSize = messageSize; 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.GiphyImage;
import org.thoughtcrime.securesms.giph.model.GiphyResponse; import org.thoughtcrime.securesms.giph.model.GiphyResponse;
import org.thoughtcrime.securesms.net.ContentProxySelector;
import org.thoughtcrime.securesms.util.AsyncLoader; import org.thoughtcrime.securesms.util.AsyncLoader;
import org.thoughtcrime.securesms.util.JsonUtils; 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) { protected GiphyLoader(@NonNull Context context, @Nullable String searchString) {
super(context); super(context);
this.searchString = searchString; this.searchString = searchString;
this.client = new OkHttpClient.Builder().proxySelector(new GiphyProxySelector()).build(); this.client = new OkHttpClient.Builder().proxySelector(new ContentProxySelector()).build();
} }
@Override @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.DataSource;
import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.engine.GlideException; 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.load.resource.gif.GifDrawable;
import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target; 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.R;
import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.giph.model.GiphyImage; 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.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
@ -70,7 +71,7 @@ class GiphyAdapter extends RecyclerView.Adapter<GiphyAdapter.GiphyViewHolder> {
Log.w(TAG, e); Log.w(TAG, e);
synchronized (this) { synchronized (this) {
if (new GiphyPaddedUrl(image.getGifUrl(), image.getGifSize()).equals(model)) { if (new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()).equals(model)) {
this.modelReady = true; this.modelReady = true;
notifyAll(); notifyAll();
} }
@ -82,7 +83,7 @@ class GiphyAdapter extends RecyclerView.Adapter<GiphyAdapter.GiphyViewHolder> {
@Override @Override
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) { public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
synchronized (this) { synchronized (this) {
if (new GiphyPaddedUrl(image.getGifUrl(), image.getGifSize()).equals(model)) { if (new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()).equals(model)) {
this.modelReady = true; this.modelReady = true;
notifyAll(); notifyAll();
} }
@ -100,8 +101,8 @@ class GiphyAdapter extends RecyclerView.Adapter<GiphyAdapter.GiphyViewHolder> {
} }
GifDrawable drawable = glideRequests.asGif() GifDrawable drawable = glideRequests.asGif()
.load(forMms ? new GiphyPaddedUrl(image.getGifMmsUrl(), image.getMmsGifSize()) : .load(forMms ? new ChunkedImageUrl(image.getGifMmsUrl(), image.getMmsGifSize()) :
new GiphyPaddedUrl(image.getGifUrl(), image.getGifSize())) new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()))
.submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
.get(); .get();
@ -148,22 +149,24 @@ class GiphyAdapter extends RecyclerView.Adapter<GiphyAdapter.GiphyViewHolder> {
holder.gifProgress.setVisibility(View.GONE); holder.gifProgress.setVisibility(View.GONE);
RequestBuilder<Drawable> thumbnailRequest = GlideApp.with(context) RequestBuilder<Drawable> thumbnailRequest = GlideApp.with(context)
.load(new GiphyPaddedUrl(image.getStillUrl(), image.getStillSize())) .load(new ChunkedImageUrl(image.getStillUrl(), image.getStillSize()))
.diskCacheStrategy(DiskCacheStrategy.ALL); .diskCacheStrategy(DiskCacheStrategy.ALL);
if (Util.isLowMemory(context)) { 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))) .placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context)))
.diskCacheStrategy(DiskCacheStrategy.ALL) .diskCacheStrategy(DiskCacheStrategy.ALL)
.transition(DrawableTransitionOptions.withCrossFade())
.listener(holder) .listener(holder)
.into(holder.thumbnail); .into(holder.thumbnail);
holder.setModelReady(); holder.setModelReady();
} else { } else {
glideRequests.load(new GiphyPaddedUrl(image.getGifUrl(), image.getGifSize())) glideRequests.load(new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()))
.thumbnail(thumbnailRequest) .thumbnail(thumbnailRequest)
.placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context))) .placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context)))
.diskCacheStrategy(DiskCacheStrategy.ALL) .diskCacheStrategy(DiskCacheStrategy.ALL)
.transition(DrawableTransitionOptions.withCrossFade())
.listener(holder) .listener(holder)
.into(holder.thumbnail); .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.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory; import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import org.thoughtcrime.securesms.giph.net.GiphyProxySelector; import org.thoughtcrime.securesms.net.ContentProxySelector;
import java.io.InputStream; import java.io.InputStream;
@ -45,7 +45,7 @@ public class OkHttpUrlLoader implements ModelLoader<GlideUrl, InputStream> {
synchronized (Factory.class) { synchronized (Factory.class) {
if (internalClient == null) { if (internalClient == null) {
internalClient = new OkHttpClient.Builder() internalClient = new OkHttpClient.Builder()
.proxySelector(new GiphyProxySelector()) .proxySelector(new ContentProxySelector())
.build(); .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); 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); long threadId = MessageSender.send(context, outgoingMessage, -1, false, null);
return new GroupActionResult(groupRecipient, threadId); return new GroupActionResult(groupRecipient, threadId);

View File

@ -212,7 +212,7 @@ public class GroupMessageProcessor {
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
Address addres = Address.fromExternal(context, GroupUtil.getEncodedId(group.getGroupId(), false)); Address addres = Address.fromExternal(context, GroupUtil.getEncodedId(group.getGroupId(), false));
Recipient recipient = Recipient.from(context, addres, 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 threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);

View File

@ -101,7 +101,7 @@ public class AttachmentUploadJob extends ContextJob implements InjectableType {
exception instanceof ConnectException; exception instanceof ConnectException;
} }
protected SignalServiceAttachment getAttachmentFor(Attachment attachment) { private SignalServiceAttachment getAttachmentFor(Attachment attachment) {
try { try {
if (attachment.getDataUri() == null || attachment.getSize() == 0) throw new IOException("Assertion failed, outgoing attachment has no data!"); if (attachment.getDataUri() == null || attachment.getSize() == 0) throw new IOException("Assertion failed, outgoing attachment has no data!");
InputStream is = PartAuthority.getAttachmentStream(context, attachment.getDataUri()); 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_READ_RECEIPTS_ENABLED = "read_receipts_enabled";
private static final String KEY_TYPING_INDICATORS_ENABLED = "typing_indicators_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_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED = "unidentified_delivery_indicators_enabled";
private static final String KEY_LINK_PREVIEWS_ENABLED = "link_previews_enabled";
@Inject transient SignalServiceMessageSender messageSender; @Inject transient SignalServiceMessageSender messageSender;
private boolean readReceiptsEnabled; private boolean readReceiptsEnabled;
private boolean typingIndicatorsEnabled; private boolean typingIndicatorsEnabled;
private boolean unidentifiedDeliveryIndicatorsEnabled; private boolean unidentifiedDeliveryIndicatorsEnabled;
private boolean linkPreviewsEnabled;
public MultiDeviceConfigurationUpdateJob(@NonNull Context context, @NonNull WorkerParameters workerParameters) { public MultiDeviceConfigurationUpdateJob(@NonNull Context context, @NonNull WorkerParameters workerParameters) {
super(context, 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() super(context, JobParameters.newBuilder()
.withGroupId("__MULTI_DEVICE_CONFIGURATION_UPDATE_JOB__") .withGroupId("__MULTI_DEVICE_CONFIGURATION_UPDATE_JOB__")
.withNetworkRequirement() .withNetworkRequirement()
@ -53,6 +60,7 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
this.readReceiptsEnabled = readReceiptsEnabled; this.readReceiptsEnabled = readReceiptsEnabled;
this.typingIndicatorsEnabled = typingIndicatorsEnabled; this.typingIndicatorsEnabled = typingIndicatorsEnabled;
this.unidentifiedDeliveryIndicatorsEnabled = unidentifiedDeliveryIndicatorsEnabled; this.unidentifiedDeliveryIndicatorsEnabled = unidentifiedDeliveryIndicatorsEnabled;
this.linkPreviewsEnabled = linkPreviewsEnabled;
} }
@Override @Override
@ -60,6 +68,7 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
readReceiptsEnabled = data.getBoolean(KEY_READ_RECEIPTS_ENABLED); readReceiptsEnabled = data.getBoolean(KEY_READ_RECEIPTS_ENABLED);
typingIndicatorsEnabled = data.getBoolean(KEY_TYPING_INDICATORS_ENABLED); typingIndicatorsEnabled = data.getBoolean(KEY_TYPING_INDICATORS_ENABLED);
unidentifiedDeliveryIndicatorsEnabled = data.getBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED); unidentifiedDeliveryIndicatorsEnabled = data.getBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED);
linkPreviewsEnabled = data.getBoolean(KEY_LINK_PREVIEWS_ENABLED);
} }
@Override @Override
@ -67,6 +76,7 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
return dataBuilder.putBoolean(KEY_READ_RECEIPTS_ENABLED, readReceiptsEnabled) return dataBuilder.putBoolean(KEY_READ_RECEIPTS_ENABLED, readReceiptsEnabled)
.putBoolean(KEY_TYPING_INDICATORS_ENABLED, typingIndicatorsEnabled) .putBoolean(KEY_TYPING_INDICATORS_ENABLED, typingIndicatorsEnabled)
.putBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED, unidentifiedDeliveryIndicatorsEnabled) .putBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED, unidentifiedDeliveryIndicatorsEnabled)
.putBoolean(KEY_LINK_PREVIEWS_ENABLED, linkPreviewsEnabled)
.build(); .build();
} }
@ -79,7 +89,8 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
messageSender.sendMessage(SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.of(readReceiptsEnabled), messageSender.sendMessage(SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.of(readReceiptsEnabled),
Optional.of(unidentifiedDeliveryIndicatorsEnabled), Optional.of(unidentifiedDeliveryIndicatorsEnabled),
Optional.of(typingIndicatorsEnabled))), Optional.of(typingIndicatorsEnabled),
Optional.of(linkPreviewsEnabled))),
UnidentifiedAccessUtil.getAccessForSync(context)); UnidentifiedAccessUtil.getAccessForSync(context));
} }

View File

@ -70,7 +70,7 @@ public class MultiDeviceReadReceiptUpdateJob extends ContextJob implements Injec
return; 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)); UnidentifiedAccessUtil.getAccessForSync(context));
} }

View File

@ -9,8 +9,11 @@ import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat; import android.support.v4.app.NotificationManagerCompat;
import android.text.TextUtils;
import android.util.Pair; import android.util.Pair;
import com.annimon.stream.Stream;
import org.signal.libsignal.metadata.InvalidMetadataMessageException; import org.signal.libsignal.metadata.InvalidMetadataMessageException;
import org.signal.libsignal.metadata.InvalidMetadataVersionException; import org.signal.libsignal.metadata.InvalidMetadataVersionException;
import org.signal.libsignal.metadata.ProtocolDuplicateMessageException; 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.groups.GroupMessageProcessor;
import org.thoughtcrime.securesms.jobmanager.JobParameters; import org.thoughtcrime.securesms.jobmanager.JobParameters;
import org.thoughtcrime.securesms.jobmanager.SafeData; 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.logging.Log;
import org.thoughtcrime.securesms.mms.IncomingMediaMessage; import org.thoughtcrime.securesms.mms.IncomingMediaMessage;
import org.thoughtcrime.securesms.mms.MmsException; 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.crypto.SignalServiceCipher;
import org.whispersystems.signalservice.api.messages.SignalServiceContent; import org.whispersystems.signalservice.api.messages.SignalServiceContent;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; 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.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
@ -227,7 +233,7 @@ public class PushDecryptJob extends ContextJob {
if (content.getDataMessage().isPresent()) { if (content.getDataMessage().isPresent()) {
SignalServiceDataMessage message = content.getDataMessage().get(); 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); if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId);
else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId); else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId);
@ -484,6 +490,7 @@ public class PushDecryptJob extends ContextJob {
message.getGroupInfo(), message.getGroupInfo(),
Optional.absent(), Optional.absent(),
Optional.absent(), Optional.absent(),
Optional.absent(),
Optional.absent()); Optional.absent());
database.insertSecureDecryptedMessageInbox(mediaMessage, -1); database.insertSecureDecryptedMessageInbox(mediaMessage, -1);
@ -518,7 +525,7 @@ public class PushDecryptJob extends ContextJob {
threadId = GroupMessageProcessor.process(context, content, message.getMessage(), true); threadId = GroupMessageProcessor.process(context, content, message.getMessage(), true);
} else if (message.getMessage().isExpirationUpdate()) { } else if (message.getMessage().isExpirationUpdate()) {
threadId = handleSynchronizeSentExpirationUpdate(message); 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); threadId = handleSynchronizeSentMediaMessage(message);
} else { } else {
threadId = handleSynchronizeSentTextMessage(message); threadId = handleSynchronizeSentTextMessage(message);
@ -581,7 +588,8 @@ public class PushDecryptJob extends ContextJob {
.add(new MultiDeviceConfigurationUpdateJob(getContext(), .add(new MultiDeviceConfigurationUpdateJob(getContext(),
TextSecurePreferences.isReadReceiptsEnabled(getContext()), TextSecurePreferences.isReadReceiptsEnabled(getContext()),
TextSecurePreferences.isTypingIndicatorsEnabled(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()); notifyTypingStoppedFromIncomingMessage(getMessageDestination(content, message), content.getSender(), content.getSenderDevice());
try { try {
MmsDatabase database = DatabaseFactory.getMmsDatabase(context); MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
Optional<QuoteModel> quote = getValidatedQuote(message.getQuote()); Optional<QuoteModel> quote = getValidatedQuote(message.getQuote());
Optional<List<Contact>> sharedContacts = getContacts(message.getSharedContacts()); Optional<List<Contact>> sharedContacts = getContacts(message.getSharedContacts());
IncomingMediaMessage mediaMessage = new IncomingMediaMessage(Address.fromExternal(context, content.getSender()), Optional<List<LinkPreview>> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or(""));
message.getTimestamp(), -1, IncomingMediaMessage mediaMessage = new IncomingMediaMessage(Address.fromExternal(context, content.getSender()),
message.getExpiresInSeconds() * 1000L, false, message.getTimestamp(), -1,
content.isNeedsReceipt(), message.getExpiresInSeconds() * 1000L, false,
message.getBody(), content.isNeedsReceipt(),
message.getGroupInfo(), message.getBody(),
message.getAttachments(), message.getGroupInfo(),
quote, message.getAttachments(),
sharedContacts); quote,
sharedContacts,
linkPreviews);
Optional<InsertResult> insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); Optional<InsertResult> insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1);
@ -673,17 +683,19 @@ public class PushDecryptJob extends ContextJob {
private long handleSynchronizeSentMediaMessage(@NonNull SentTranscriptMessage message) private long handleSynchronizeSentMediaMessage(@NonNull SentTranscriptMessage message)
throws MmsException throws MmsException
{ {
MmsDatabase database = DatabaseFactory.getMmsDatabase(context); MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
Recipient recipients = getSyncMessageDestination(message); Recipient recipients = getSyncMessageDestination(message);
Optional<QuoteModel> quote = getValidatedQuote(message.getMessage().getQuote()); Optional<QuoteModel> quote = getValidatedQuote(message.getMessage().getQuote());
Optional<List<Contact>> sharedContacts = getContacts(message.getMessage().getSharedContacts()); Optional<List<Contact>> sharedContacts = getContacts(message.getMessage().getSharedContacts());
OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipients, message.getMessage().getBody().orNull(), Optional<List<LinkPreview>> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or(""));
PointerAttachment.forPointers(message.getMessage().getAttachments()), OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipients, message.getMessage().getBody().orNull(),
message.getTimestamp(), -1, PointerAttachment.forPointers(message.getMessage().getAttachments()),
message.getMessage().getExpiresInSeconds() * 1000, message.getTimestamp(), -1,
ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(), message.getMessage().getExpiresInSeconds() * 1000,
sharedContacts.or(Collections.emptyList()), ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(),
Collections.emptyList(), Collections.emptyList()); sharedContacts.or(Collections.emptyList()),
previews.or(Collections.emptyList()),
Collections.emptyList(), Collections.emptyList());
mediaMessage = new OutgoingSecureMediaMessage(mediaMessage); mediaMessage = new OutgoingSecureMediaMessage(mediaMessage);
@ -784,7 +796,7 @@ public class PushDecryptJob extends ContextJob {
long messageId; long messageId;
if (isGroup) { 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); outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage);
messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, null); messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, null);
@ -1003,7 +1015,14 @@ public class PushDecryptJob extends ContextJob {
List<Attachment> attachments = new LinkedList<>(); List<Attachment> attachments = new LinkedList<>();
if (message.isMms()) { 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)); 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); 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) { private Optional<InsertResult> insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp) {
SmsDatabase database = DatabaseFactory.getSmsDatabase(context); SmsDatabase database = DatabaseFactory.getSmsDatabase(context);
IncomingTextMessage textMessage = new IncomingTextMessage(Address.fromExternal(context, sender), 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.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; 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.SignalServiceDataMessage.Quote;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.shared.SharedContact; 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.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -93,16 +95,15 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
@WorkerThread @WorkerThread
public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Address destination, @Nullable Address filterAddress) { public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Address destination, @Nullable Address filterAddress) {
try { try {
MmsDatabase database = DatabaseFactory.getMmsDatabase(context); MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
OutgoingMediaMessage message = database.getOutgoingMessage(messageId); OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
List<Attachment> attachments = new LinkedList<>();
if (message.isGroup()) { attachments.addAll(message.getAttachments());
Log.i(TAG, "Group update message. Using legacy attachment upload path."); attachments.addAll(Stream.of(message.getLinkPreviews()).filter(p -> p.getThumbnail().isPresent()).map(p -> p.getThumbnail().get()).toList());
jobManager.add(new PushGroupSendJob(context, messageId, destination, filterAddress)); attachments.addAll(Stream.of(message.getSharedContacts()).filter(c -> c.getAvatar() != null).map(c -> c.getAvatar().getAttachment()).withoutNulls().toList());
return;
}
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(); ChainParameters chainParams = new ChainParameters.Builder().setGroupId(destination.serialize()).build();
if (attachmentJobs.isEmpty()) { if (attachmentJobs.isEmpty()) {
@ -237,7 +238,9 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
Optional<byte[]> profileKey = getProfileKey(message.getRecipient()); Optional<byte[]> profileKey = getProfileKey(message.getRecipient());
Optional<Quote> quote = getQuoteFor(message); Optional<Quote> quote = getQuoteFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message); List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<Preview> previews = getPreviewsFor(message);
List<SignalServiceAddress> addresses = Stream.of(destinations).map(this::getPushAddress).toList(); List<SignalServiceAddress> addresses = Stream.of(destinations).map(this::getPushAddress).toList();
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(message.getAttachments());
List<Optional<UnidentifiedAccessPair>> unidentifiedAccess = Stream.of(addresses) List<Optional<UnidentifiedAccessPair>> unidentifiedAccess = Stream.of(addresses)
.map(address -> Address.fromSerialized(address.getNumber())) .map(address -> Address.fromSerialized(address.getNumber()))
@ -246,13 +249,9 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
.toList(); .toList();
if (message.isGroup()) { if (message.isGroup()) {
MediaConstraints mediaConstraints = MediaConstraints.getPushMediaConstraints();
List<Attachment> scaledAttachments = scaleAndStripExifFromAttachments(mediaConstraints, message.getAttachments());
List<SignalServiceAttachment> attachmentStreams = getAttachmentsFor(scaledAttachments);
OutgoingGroupMediaMessage groupMessage = (OutgoingGroupMediaMessage) message; OutgoingGroupMediaMessage groupMessage = (OutgoingGroupMediaMessage) message;
GroupContext groupContext = groupMessage.getGroupContext(); 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.Type type = groupMessage.isGroupQuit() ? SignalServiceGroup.Type.QUIT : SignalServiceGroup.Type.UPDATE;
SignalServiceGroup group = new SignalServiceGroup(type, GroupUtil.getDecodedId(groupId), groupContext.getName(), groupContext.getMembersList(), avatar); SignalServiceGroup group = new SignalServiceGroup(type, GroupUtil.getDecodedId(groupId), groupContext.getName(), groupContext.getMembersList(), avatar);
SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder() SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder()
@ -263,8 +262,6 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
return messageSender.sendMessage(addresses, unidentifiedAccess, groupDataMessage); return messageSender.sendMessage(addresses, unidentifiedAccess, groupDataMessage);
} else { } else {
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(message.getAttachments());
SignalServiceGroup group = new SignalServiceGroup(GroupUtil.getDecodedId(groupId)); SignalServiceGroup group = new SignalServiceGroup(GroupUtil.getDecodedId(groupId));
SignalServiceDataMessage groupMessage = SignalServiceDataMessage.newBuilder() SignalServiceDataMessage groupMessage = SignalServiceDataMessage.newBuilder()
.withTimestamp(message.getSentTimeMillis()) .withTimestamp(message.getSentTimeMillis())
@ -276,6 +273,7 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType {
.withProfileKey(profileKey.orNull()) .withProfileKey(profileKey.orNull())
.withQuote(quote.orNull()) .withQuote(quote.orNull())
.withSharedContacts(sharedContacts) .withSharedContacts(sharedContacts)
.withPreviews(previews)
.build(); .build();
return messageSender.sendMessage(addresses, unidentifiedAccess, groupMessage); return messageSender.sendMessage(addresses, unidentifiedAccess, groupMessage);

View File

@ -7,9 +7,11 @@ import android.support.annotation.WorkerThread;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException; 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.ChainParameters;
import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.SafeData; import org.thoughtcrime.securesms.jobmanager.SafeData;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; 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.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; 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.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import javax.inject.Inject; import javax.inject.Inject;
@ -69,9 +74,15 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType {
@WorkerThread @WorkerThread
public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Address destination) { public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Address destination) {
try { try {
MmsDatabase database = DatabaseFactory.getMmsDatabase(context); MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
OutgoingMediaMessage message = database.getOutgoingMessage(messageId); OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
List<AttachmentUploadJob> attachmentJobs = Stream.of(message.getAttachments()).map(a -> new AttachmentUploadJob(context, ((DatabaseAttachment) a).getAttachmentId())).toList(); 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(); ChainParameters chainParams = new ChainParameters.Builder().setGroupId(destination.serialize()).build();
if (attachmentJobs.isEmpty()) { if (attachmentJobs.isEmpty()) {
@ -191,6 +202,7 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType {
Optional<byte[]> profileKey = getProfileKey(message.getRecipient()); Optional<byte[]> profileKey = getProfileKey(message.getRecipient());
Optional<SignalServiceDataMessage.Quote> quote = getQuoteFor(message); Optional<SignalServiceDataMessage.Quote> quote = getQuoteFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message); List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<Preview> previews = getPreviewsFor(message);
SignalServiceDataMessage mediaMessage = SignalServiceDataMessage.newBuilder() SignalServiceDataMessage mediaMessage = SignalServiceDataMessage.newBuilder()
.withBody(message.getBody()) .withBody(message.getBody())
.withAttachments(serviceAttachments) .withAttachments(serviceAttachments)
@ -199,6 +211,7 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType {
.withProfileKey(profileKey.orNull()) .withProfileKey(profileKey.orNull())
.withQuote(quote.orNull()) .withQuote(quote.orNull())
.withSharedContacts(sharedContacts) .withSharedContacts(sharedContacts)
.withPreviews(previews)
.asExpirationUpdate(message.isExpirationUpdate()) .asExpirationUpdate(message.isExpirationUpdate())
.build(); .build();

View File

@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.events.PartProgressEvent; import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobmanager.JobParameters; import org.thoughtcrime.securesms.jobmanager.JobParameters;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; 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.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; 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.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; 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 String TAG = PushSendJob.class.getSimpleName();
private static final long CERTIFICATE_EXPIRATION_BUFFER = TimeUnit.DAYS.toMillis(1); 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); super(context, workerParameters);
} }
@ -247,6 +249,13 @@ public abstract class PushSendJob extends SendJob {
return sharedContacts; 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 { protected void rotateSenderCertificateIfNecessary() throws IOException {
try { try {
byte[] certificateBytes = TextSecurePreferences.getUnidentifiedAccessCertificate(context); 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.attachments.PointerAttachment;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.GroupUtil;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@ -26,8 +27,9 @@ public class IncomingMediaMessage {
private final QuoteModel quote; private final QuoteModel quote;
private final boolean unidentified; private final boolean unidentified;
private final List<Attachment> attachments = new LinkedList<>(); private final List<Attachment> attachments = new LinkedList<>();
private final List<Contact> sharedContacts = new LinkedList<>(); private final List<Contact> sharedContacts = new LinkedList<>();
private final List<LinkPreview> linkPreviews = new LinkedList<>();
public IncomingMediaMessage(Address from, public IncomingMediaMessage(Address from,
Optional<Address> groupId, Optional<Address> groupId,
@ -63,7 +65,8 @@ public class IncomingMediaMessage {
Optional<SignalServiceGroup> group, Optional<SignalServiceGroup> group,
Optional<List<SignalServiceAttachment>> attachments, Optional<List<SignalServiceAttachment>> attachments,
Optional<QuoteModel> quote, Optional<QuoteModel> quote,
Optional<List<Contact>> sharedContacts) Optional<List<Contact>> sharedContacts,
Optional<List<LinkPreview>> linkPreviews)
{ {
this.push = true; this.push = true;
this.from = from; this.from = from;
@ -80,6 +83,7 @@ public class IncomingMediaMessage {
this.attachments.addAll(PointerAttachment.forPointers(attachments)); this.attachments.addAll(PointerAttachment.forPointers(attachments));
this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList())); this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList()));
this.linkPreviews.addAll(linkPreviews.or(Collections.emptyList()));
} }
public int getSubscriptionId() { public int getSubscriptionId() {
@ -130,6 +134,10 @@ public class IncomingMediaMessage {
return sharedContacts; return sharedContacts;
} }
public List<LinkPreview> getLinkPreviews() {
return linkPreviews;
}
public boolean isUnidentified() { public boolean isUnidentified() {
return unidentified; return unidentified;
} }

View File

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

View File

@ -6,6 +6,7 @@ import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.Base64;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
@ -24,11 +25,12 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage {
long sentTimeMillis, long sentTimeMillis,
long expiresIn, long expiresIn,
@Nullable QuoteModel quote, @Nullable QuoteModel quote,
@NonNull List<Contact> contacts) @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> previews)
throws IOException throws IOException
{ {
super(recipient, encodedGroupContext, avatar, sentTimeMillis, 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)); this.group = GroupContext.parseFrom(Base64.decode(encodedGroupContext));
} }
@ -39,12 +41,13 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage {
long sentTimeMillis, long sentTimeMillis,
long expireIn, long expireIn,
@Nullable QuoteModel quote, @Nullable QuoteModel quote,
@NonNull List<Contact> contacts) @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> previews)
{ {
super(recipient, Base64.encodeBytes(group.toByteArray()), super(recipient, Base64.encodeBytes(group.toByteArray()),
new LinkedList<Attachment>() {{if (avatar != null) add(avatar);}}, new LinkedList<Attachment>() {{if (avatar != null) add(avatar);}},
System.currentTimeMillis(), System.currentTimeMillis(),
ThreadDatabase.DistributionTypes.CONVERSATION, expireIn, quote, contacts); ThreadDatabase.DistributionTypes.CONVERSATION, expireIn, quote, contacts, previews);
this.group = group; 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.contactshare.Contact;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.LinkedList; import java.util.LinkedList;
@ -27,6 +28,7 @@ public class OutgoingMediaMessage {
private final List<NetworkFailure> networkFailures = new LinkedList<>(); private final List<NetworkFailure> networkFailures = new LinkedList<>();
private final List<IdentityKeyMismatch> identityKeyMismatches = new LinkedList<>(); private final List<IdentityKeyMismatch> identityKeyMismatches = new LinkedList<>();
private final List<Contact> contacts = new LinkedList<>(); private final List<Contact> contacts = new LinkedList<>();
private final List<LinkPreview> linkPreviews = new LinkedList<>();
public OutgoingMediaMessage(Recipient recipient, String message, public OutgoingMediaMessage(Recipient recipient, String message,
List<Attachment> attachments, long sentTimeMillis, List<Attachment> attachments, long sentTimeMillis,
@ -34,6 +36,7 @@ public class OutgoingMediaMessage {
int distributionType, int distributionType,
@Nullable QuoteModel outgoingQuote, @Nullable QuoteModel outgoingQuote,
@NonNull List<Contact> contacts, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews,
@NonNull List<NetworkFailure> networkFailures, @NonNull List<NetworkFailure> networkFailures,
@NonNull List<IdentityKeyMismatch> identityKeyMismatches) @NonNull List<IdentityKeyMismatch> identityKeyMismatches)
{ {
@ -47,18 +50,22 @@ public class OutgoingMediaMessage {
this.outgoingQuote = outgoingQuote; this.outgoingQuote = outgoingQuote;
this.contacts.addAll(contacts); this.contacts.addAll(contacts);
this.linkPreviews.addAll(linkPreviews);
this.networkFailures.addAll(networkFailures); this.networkFailures.addAll(networkFailures);
this.identityKeyMismatches.addAll(identityKeyMismatches); 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, this(recipient,
buildMessage(slideDeck, message), buildMessage(slideDeck, message),
slideDeck.asAttachments(), slideDeck.asAttachments(),
sentTimeMillis, subscriptionId, sentTimeMillis, subscriptionId,
expiresIn, distributionType, outgoingQuote, expiresIn, distributionType, outgoingQuote,
contacts, new LinkedList<>(), new LinkedList<>()); contacts, linkPreviews, new LinkedList<>(), new LinkedList<>());
} }
public OutgoingMediaMessage(OutgoingMediaMessage that) { public OutgoingMediaMessage(OutgoingMediaMessage that) {
@ -74,6 +81,7 @@ public class OutgoingMediaMessage {
this.identityKeyMismatches.addAll(that.identityKeyMismatches); this.identityKeyMismatches.addAll(that.identityKeyMismatches);
this.networkFailures.addAll(that.networkFailures); this.networkFailures.addAll(that.networkFailures);
this.contacts.addAll(that.contacts); this.contacts.addAll(that.contacts);
this.linkPreviews.addAll(that.linkPreviews);
} }
public Recipient getRecipient() { public Recipient getRecipient() {
@ -124,6 +132,10 @@ public class OutgoingMediaMessage {
return contacts; return contacts;
} }
public @NonNull List<LinkPreview> getLinkPreviews() {
return linkPreviews;
}
public @NonNull List<NetworkFailure> getNetworkFailures() { public @NonNull List<NetworkFailure> getNetworkFailures() {
return networkFailures; return networkFailures;
} }

View File

@ -5,6 +5,7 @@ import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Collections; import java.util.Collections;
@ -18,9 +19,10 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage {
int distributionType, int distributionType,
long expiresIn, long expiresIn,
@Nullable QuoteModel quote, @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) { 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.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.crypto.AttachmentSecret; import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; 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.ContactPhotoLoader;
import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapCacheDecoder; import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapCacheDecoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedCacheEncoder; import org.thoughtcrime.securesms.glide.cache.EncryptedCacheEncoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedGifCacheDecoder; import org.thoughtcrime.securesms.glide.cache.EncryptedGifCacheDecoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapResourceEncoder; import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapResourceEncoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedGifDrawableResourceEncoder; 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.glide.OkHttpUrlLoader;
import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel; import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; 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(ContactPhoto.class, InputStream.class, new ContactPhotoLoader.Factory(context));
registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context)); registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context));
registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory()); 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()); 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()) { if (recipient.isGroupRecipient()) {
Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); 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); replyThreadId = MessageSender.send(context, reply, threadId, false, null);
} else { } else {
Log.w("AndroidAutoReplyReceiver", "Sending regular message "); Log.w("AndroidAutoReplyReceiver", "Sending regular message ");

View File

@ -72,7 +72,7 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
long expiresIn = recipient.getExpireMessages() * 1000L; long expiresIn = recipient.getExpireMessages() * 1000L;
if (recipient.isGroupRecipient()) { 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); threadId = MessageSender.send(context, reply, -1, false, null);
} else if (TextSecurePreferences.isPushRegistered(context) && recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { } else if (TextSecurePreferences.isPushRegistered(context) && recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
OutgoingEncryptedMessage reply = new OutgoingEncryptedMessage(recipient, responseText.toString(), expiresIn); 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.PASSPHRASE_TIMEOUT_INTERVAL_PREF).setOnPreferenceClickListener(new PassphraseIntervalClickListener());
this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener()); this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener());
this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener()); 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(PREFERENCE_CATEGORY_BLOCKED).setOnPreferenceClickListener(new BlockedContactsClickListener());
this.findPreference(TextSecurePreferences.SHOW_UNIDENTIFIED_DELIVERY_INDICATORS).setOnPreferenceChangeListener(new ShowUnidentifiedDeliveryIndicatorsChangedListener()); this.findPreference(TextSecurePreferences.SHOW_UNIDENTIFIED_DELIVERY_INDICATORS).setOnPreferenceChangeListener(new ShowUnidentifiedDeliveryIndicatorsChangedListener());
this.findPreference(TextSecurePreferences.UNIVERSAL_UNIDENTIFIED_ACCESS).setOnPreferenceChangeListener(new UniversalUnidentifiedAccessChangedListener()); this.findPreference(TextSecurePreferences.UNIVERSAL_UNIDENTIFIED_ACCESS).setOnPreferenceChangeListener(new UniversalUnidentifiedAccessChangedListener());
@ -189,7 +190,8 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
.add(new MultiDeviceConfigurationUpdateJob(getContext(), .add(new MultiDeviceConfigurationUpdateJob(getContext(),
enabled, enabled,
TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()),
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()))); TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
return true; return true;
} }
@ -200,11 +202,12 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
public boolean onPreferenceChange(Preference preference, Object newValue) { public boolean onPreferenceChange(Preference preference, Object newValue) {
boolean enabled = (boolean)newValue; boolean enabled = (boolean)newValue;
ApplicationContext.getInstance(getContext()) ApplicationContext.getInstance(getContext())
.getJobManager() .getJobManager()
.add(new MultiDeviceConfigurationUpdateJob(getContext(), .add(new MultiDeviceConfigurationUpdateJob(getContext(),
TextSecurePreferences.isReadReceiptsEnabled(requireContext()), TextSecurePreferences.isReadReceiptsEnabled(requireContext()),
enabled, enabled,
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()))); TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()),
TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
if (!enabled) { if (!enabled) {
ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().clear(); 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) { public static CharSequence getSummary(Context context) {
final int privacySummaryResId = R.string.ApplicationPreferencesActivity_privacy_summary; final int privacySummaryResId = R.string.ApplicationPreferencesActivity_privacy_summary;
final String onRes = context.getString(R.string.ApplicationPreferencesActivity_on); 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) { public boolean onPreferenceChange(Preference preference, Object newValue) {
boolean enabled = (boolean) newValue; boolean enabled = (boolean) newValue;
ApplicationContext.getInstance(getContext()) ApplicationContext.getInstance(getContext())
.getJobManager() .getJobManager()
.add(new MultiDeviceConfigurationUpdateJob(getContext(), .add(new MultiDeviceConfigurationUpdateJob(getContext(),
TextSecurePreferences.isReadReceiptsEnabled(getContext()), TextSecurePreferences.isReadReceiptsEnabled(getContext()),
TextSecurePreferences.isTypingIndicatorsEnabled(getContext()), TextSecurePreferences.isTypingIndicatorsEnabled(getContext()),
enabled)); enabled,
TextSecurePreferences.isLinkPreviewsEnabled(getContext())));
return true; return true;
} }

View File

@ -43,6 +43,21 @@ public class MemoryBlobProvider {
cache.remove(ContentUris.parseId(uri)); 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 { public synchronized @NonNull InputStream getStream(long id) throws IOException {
Entry entry = cache.get(id); 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.scribbles.widget.entity.TextEntity;
import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util; 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.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.whispersystems.libsignal.util.guava.Optional; 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) { if (resultCode == RESULT_OK && requestCode == SELECT_STICKER_REQUEST_CODE && data != null) {
final String stickerFile = data.getStringExtra(StickerSelectActivity.EXTRA_STICKER_FILE); final String stickerFile = data.getStringExtra(StickerSelectActivity.EXTRA_STICKER_FILE);
LifecycleBoundTask.run(getLifecycle(), () -> { SimpleTask.run(getLifecycle(), () -> {
try { try {
return BitmapFactory.decodeStream(getContext().getAssets().open(stickerFile)); return BitmapFactory.decodeStream(getContext().getAssets().open(stickerFile));
} catch (IOException e) { } catch (IOException e) {

View File

@ -74,7 +74,7 @@ public class GroupUtil {
.setType(GroupContext.Type.QUIT) .setType(GroupContext.Type.QUIT)
.build(); .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 TYPING_INDICATORS = "pref_typing_indicators";
public static final String LINK_PREVIEWS = "pref_link_previews";
public static boolean isScreenLockEnabled(@NonNull Context context) { public static boolean isScreenLockEnabled(@NonNull Context context) {
return getBooleanPreference(context, SCREEN_LOCK, false); return getBooleanPreference(context, SCREEN_LOCK, false);
} }
@ -346,6 +348,10 @@ public class TextSecurePreferences {
setBooleanPreference(context, TYPING_INDICATORS, enabled); setBooleanPreference(context, TYPING_INDICATORS, enabled);
} }
public static boolean isLinkPreviewsEnabled(Context context) {
return getBooleanPreference(context, LINK_PREVIEWS, true);
}
public static @Nullable String getProfileKey(Context context) { public static @Nullable String getProfileKey(Context context) {
return getStringPreference(context, PROFILE_KEY_PREF, null); return getStringPreference(context, PROFILE_KEY_PREF, null);
} }

View File

@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.util.Util;
import java.util.concurrent.Callable; 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 * 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) { private static boolean isValid(@NonNull Lifecycle lifecycle) {
return lifecycle.getCurrentState().isAtLeast(Lifecycle.State.CREATED); return lifecycle.getCurrentState().isAtLeast(Lifecycle.State.CREATED);
} }