From c77809fa90f47f8cbbc70eccb9497ca1af03fe1a Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 11 Jun 2019 02:18:45 -0400 Subject: [PATCH] Add support for view-once messages. --- AndroidManifest.xml | 11 +- res/drawable-hdpi/ic_view_infinite_32.png | Bin 0 -> 3443 bytes res/drawable-hdpi/ic_view_once_32.png | Bin 0 -> 3407 bytes res/drawable-mdpi/ic_view_infinite_32.png | Bin 0 -> 1944 bytes res/drawable-mdpi/ic_view_once_32.png | Bin 0 -> 1926 bytes res/drawable-xhdpi/ic_view_infinite_32.png | Bin 0 -> 5156 bytes res/drawable-xhdpi/ic_view_once_32.png | Bin 0 -> 5042 bytes res/drawable-xxhdpi/ic_view_infinite_32.png | Bin 0 -> 9369 bytes res/drawable-xxhdpi/ic_view_once_32.png | Bin 0 -> 8850 bytes res/drawable-xxxhdpi/ic_view_infinite_32.png | Bin 0 -> 13850 bytes res/drawable-xxxhdpi/ic_view_once_32.png | Bin 0 -> 13141 bytes .../ic_arrow_down_circle_outline_24.xml | 9 + res/drawable/ic_play_outline_24.xml | 9 + res/drawable/ic_play_solid_24.xml | 9 + res/drawable/ic_timer_24.xml | 9 + res/drawable/ic_timer_disabled_24.xml | 9 + res/layout/conversation_item_received.xml | 10 + .../conversation_item_received_revealable.xml | 12 ++ res/layout/conversation_item_sent.xml | 10 + .../conversation_item_sent_revealable.xml | 12 ++ res/layout/mediasend_activity.xml | 9 + res/layout/revealable_message_activity.xml | 24 +++ res/layout/revealable_message_view.xml | 50 +++++ res/values/attrs.xml | 6 + res/values/strings.xml | 9 + res/values/themes.xml | 6 + .../securesms/ApplicationContext.java | 23 ++- .../securesms/BindableConversationItem.java | 1 + .../attachments/TombstoneAttachment.java | 25 +++ .../securesms/backup/FullBackupExporter.java | 14 +- .../securesms/components/Outliner.java | 12 +- .../conversation/ConversationActivity.java | 32 +++- .../conversation/ConversationFragment.java | 36 ++++ .../conversation/ConversationItem.java | 118 ++++++++++-- .../database/AttachmentDatabase.java | 49 ++++- .../securesms/database/MediaDatabase.java | 1 + .../securesms/database/MessagingDatabase.java | 4 + .../securesms/database/MmsDatabase.java | 115 +++++++++++- .../securesms/database/MmsSmsColumns.java | 5 + .../securesms/database/MmsSmsDatabase.java | 14 +- .../securesms/database/SmsDatabase.java | 7 +- .../securesms/database/ThreadDatabase.java | 103 ++++++++++- .../database/helpers/SQLCipherOpenHelper.java | 11 +- .../loaders/ConversationListLoader.java | 3 +- .../database/model/MediaMmsMessageRecord.java | 7 +- .../database/model/MmsMessageRecord.java | 20 +- .../model/NotificationMmsMessageRecord.java | 2 +- .../database/model/SmsMessageRecord.java | 2 + .../database/model/ThreadRecord.java | 16 +- .../securesms/groups/GroupManager.java | 2 +- .../groups/GroupMessageProcessor.java | 4 +- .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/jobs/MmsDownloadJob.java | 2 +- .../jobs/MultiDeviceRevealUpdateJob.java | 124 +++++++++++++ .../securesms/jobs/PushDecryptJob.java | 87 +++++++-- .../securesms/jobs/PushGroupSendJob.java | 8 + .../securesms/jobs/PushMediaSendJob.java | 8 + .../mediasend/MediaSendActivity.java | 25 ++- .../mediasend/MediaSendViewModel.java | 135 ++++++++------ .../securesms/mms/IncomingMediaMessage.java | 9 + .../mms/OutgoingExpirationUpdateMessage.java | 2 +- .../mms/OutgoingGroupMediaMessage.java | 6 +- .../securesms/mms/OutgoingMediaMessage.java | 14 +- .../mms/OutgoingSecureMediaMessage.java | 3 +- .../AndroidAutoReplyReceiver.java | 2 +- .../notifications/MessageNotifier.java | 3 + .../notifications/RemoteReplyReceiver.java | 2 +- .../revealable/RevealExpirationInfo.java | 32 ++++ .../revealable/RevealableMessageActivity.java | 71 +++++++ .../revealable/RevealableMessageManager.java | 87 +++++++++ .../RevealableMessageRepository.java | 37 ++++ .../revealable/RevealableMessageView.java | 174 ++++++++++++++++++ .../RevealableMessageViewModel.java | 96 ++++++++++ .../securesms/revealable/RevealableUtil.java | 61 ++++++ .../securesms/service/TimedEventManager.java | 95 ++++++++++ .../securesms/sms/IncomingJoinedMessage.java | 2 +- .../securesms/sms/IncomingTextMessage.java | 15 +- .../securesms/util/GroupUtil.java | 2 +- .../securesms/util/IdentityUtil.java | 8 +- .../securesms/util/TextSecurePreferences.java | 10 + 80 files changed, 1803 insertions(+), 148 deletions(-) create mode 100644 res/drawable-hdpi/ic_view_infinite_32.png create mode 100644 res/drawable-hdpi/ic_view_once_32.png create mode 100644 res/drawable-mdpi/ic_view_infinite_32.png create mode 100644 res/drawable-mdpi/ic_view_once_32.png create mode 100644 res/drawable-xhdpi/ic_view_infinite_32.png create mode 100644 res/drawable-xhdpi/ic_view_once_32.png create mode 100644 res/drawable-xxhdpi/ic_view_infinite_32.png create mode 100644 res/drawable-xxhdpi/ic_view_once_32.png create mode 100644 res/drawable-xxxhdpi/ic_view_infinite_32.png create mode 100644 res/drawable-xxxhdpi/ic_view_once_32.png create mode 100644 res/drawable/ic_arrow_down_circle_outline_24.xml create mode 100644 res/drawable/ic_play_outline_24.xml create mode 100644 res/drawable/ic_play_solid_24.xml create mode 100644 res/drawable/ic_timer_24.xml create mode 100644 res/drawable/ic_timer_disabled_24.xml create mode 100644 res/layout/conversation_item_received_revealable.xml create mode 100644 res/layout/conversation_item_sent_revealable.xml create mode 100644 res/layout/revealable_message_activity.xml create mode 100644 res/layout/revealable_message_view.xml create mode 100644 src/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java create mode 100644 src/org/thoughtcrime/securesms/jobs/MultiDeviceRevealUpdateJob.java create mode 100644 src/org/thoughtcrime/securesms/revealable/RevealExpirationInfo.java create mode 100644 src/org/thoughtcrime/securesms/revealable/RevealableMessageActivity.java create mode 100644 src/org/thoughtcrime/securesms/revealable/RevealableMessageManager.java create mode 100644 src/org/thoughtcrime/securesms/revealable/RevealableMessageRepository.java create mode 100644 src/org/thoughtcrime/securesms/revealable/RevealableMessageView.java create mode 100644 src/org/thoughtcrime/securesms/revealable/RevealableMessageViewModel.java create mode 100644 src/org/thoughtcrime/securesms/revealable/RevealableUtil.java create mode 100644 src/org/thoughtcrime/securesms/service/TimedEventManager.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index b06617ec3d..a6cc1bd48f 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -299,7 +299,7 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> @@ -336,6 +336,13 @@ android:windowSoftInputMode="stateUnchanged" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> + + + + Px?ElET{RA>d&nR!rGM-snbspx?AzS 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 VgH!Z;s22bL002ovPDHLkV1j14uYCXj literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/ic_view_once_32.png b/res/drawable-hdpi/ic_view_once_32.png new file mode 100644 index 0000000000000000000000000000000000000000..16ddff5f92ac628f97f5d21ec2b802b14e5d25bc GIT binary patch literal 3407 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 M07*qoM6N<$f~2gy>i_@% literal 0 HcmV?d00001 diff --git a/res/drawable-xhdpi/ic_view_infinite_32.png b/res/drawable-xhdpi/ic_view_infinite_32.png new file mode 100644 index 0000000000000000000000000000000000000000..4db9a007cb779edaaaef372bcad8b23e459f1a7f GIT binary patch literal 5156 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 IM6N<$f>FVoC;$Ke literal 0 HcmV?d00001 diff --git a/res/drawable-xxhdpi/ic_view_infinite_32.png b/res/drawable-xxhdpi/ic_view_infinite_32.png new file mode 100644 index 0000000000000000000000000000000000000000..812c7a79d9750bee3bafa2a704191e75a893a25c GIT binary patch literal 9369 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 Td1sbw00000NkvXXu0mjf$Cnw> literal 0 HcmV?d00001 diff --git a/res/drawable-xxhdpi/ic_view_once_32.png b/res/drawable-xxhdpi/ic_view_once_32.png new file mode 100644 index 0000000000000000000000000000000000000000..e77b837b8935836d92cb4370ec7cc86672fad311 GIT binary patch literal 8850 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 literal 0 HcmV?d00001 diff --git a/res/drawable-xxxhdpi/ic_view_infinite_32.png b/res/drawable-xxxhdpi/ic_view_infinite_32.png new file mode 100644 index 0000000000000000000000000000000000000000..f7ebc26695b0b23e9cf305fe1d4be8a7265f344c GIT binary patch literal 13850 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 + + diff --git a/res/drawable/ic_play_outline_24.xml b/res/drawable/ic_play_outline_24.xml new file mode 100644 index 0000000000..c9b8c5f0b5 --- /dev/null +++ b/res/drawable/ic_play_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/drawable/ic_play_solid_24.xml b/res/drawable/ic_play_solid_24.xml new file mode 100644 index 0000000000..ec9e2ba5b0 --- /dev/null +++ b/res/drawable/ic_play_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/drawable/ic_timer_24.xml b/res/drawable/ic_timer_24.xml new file mode 100644 index 0000000000..a96076ca3c --- /dev/null +++ b/res/drawable/ic_timer_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/drawable/ic_timer_disabled_24.xml b/res/drawable/ic_timer_disabled_24.xml new file mode 100644 index 0000000000..f232761ec5 --- /dev/null +++ b/res/drawable/ic_timer_disabled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml index 82451b14d6..b9adf27b26 100644 --- a/res/layout/conversation_item_received.xml +++ b/res/layout/conversation_item_received.xml @@ -153,6 +153,16 @@ android:layout_marginStart="@dimen/message_bubble_horizontal_padding" android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" /> + + + diff --git a/res/layout/conversation_item_sent.xml b/res/layout/conversation_item_sent.xml index 48fc73b18a..bda67aecfc 100644 --- a/res/layout/conversation_item_sent.xml +++ b/res/layout/conversation_item_sent.xml @@ -94,6 +94,16 @@ android:layout_marginStart="@dimen/message_bubble_horizontal_padding" android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" /> + + + diff --git a/res/layout/mediasend_activity.xml b/res/layout/mediasend_activity.xml index 6caab7a11d..c404cd9141 100644 --- a/res/layout/mediasend_activity.xml +++ b/res/layout/mediasend_activity.xml @@ -60,6 +60,15 @@ android:layout_marginEnd="16dp" android:layout_marginBottom="12dp" android:orientation="horizontal"> + + + + + + + + + \ No newline at end of file diff --git a/res/layout/revealable_message_view.xml b/res/layout/revealable_message_view.xml new file mode 100644 index 0000000000..c300c25c1d --- /dev/null +++ b/res/layout/revealable_message_view.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 08ef25fb1c..2754046368 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -94,6 +94,7 @@ + @@ -347,4 +348,9 @@ + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index 1f033ab244..fd9a2b082f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -639,6 +639,11 @@ Enter the code we sent to %s Call + + View Photo + Viewed + Photo + Failed to save image changes @@ -677,6 +682,7 @@ %s reset the secure session. Duplicate message. This message could not be processed because it was sent from a newer version of Signal. You can ask your contact to send this message again after you update. + Error handling incoming message Stickers @@ -707,6 +713,8 @@ Called you Missed call Media message + Sticker + Disappearing photo %s is on Signal! Disappearing messages disabled Disappearing message time set to %s @@ -794,6 +802,7 @@ Mark read Media message Sticker + Disappearing photo Reply Signal Message Unsecured SMS diff --git a/res/values/themes.xml b/res/values/themes.xml index 4be64a9916..fa31a40658 100644 --- a/res/values/themes.xml +++ b/res/values/themes.xml @@ -219,6 +219,7 @@ @drawable/sticky_date_header_background_light @color/core_grey_60 @color/transparent_black_30 + @color/core_white @drawable/quick_camera_light @drawable/ic_mic_grey600_24dp @@ -355,6 +356,7 @@ @drawable/sticky_date_header_background_dark @color/core_grey_25 @color/transparent_white_30 + @color/core_black @drawable/contact_list_divider_dark @@ -495,4 +497,8 @@ + + diff --git a/src/org/thoughtcrime/securesms/ApplicationContext.java b/src/org/thoughtcrime/securesms/ApplicationContext.java index 94158d3817..7d166bdb84 100644 --- a/src/org/thoughtcrime/securesms/ApplicationContext.java +++ b/src/org/thoughtcrime/securesms/ApplicationContext.java @@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.IncomingMessageObserver; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.LocalBackupListener; +import org.thoughtcrime.securesms.revealable.RevealableMessageManager; import org.thoughtcrime.securesms.service.RotateSenderCertificateListener; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; @@ -90,12 +91,13 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi private static final String TAG = ApplicationContext.class.getSimpleName(); - private ExpiringMessageManager expiringMessageManager; - private TypingStatusRepository typingStatusRepository; - private TypingStatusSender typingStatusSender; - private JobManager jobManager; - private IncomingMessageObserver incomingMessageObserver; - private PersistentLogger persistentLogger; + private ExpiringMessageManager expiringMessageManager; + private RevealableMessageManager revealableMessageManager; + private TypingStatusRepository typingStatusRepository; + private TypingStatusSender typingStatusSender; + private JobManager jobManager; + private IncomingMessageObserver incomingMessageObserver; + private PersistentLogger persistentLogger; private volatile boolean isAppVisible; @@ -114,6 +116,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi initializeJobManager(); initializeMessageRetrieval(); initializeExpiringMessageManager(); + initializeRevealableMessageManager(); initializeTypingStatusRepository(); initializeTypingStatusSender(); initializeGcmCheck(); @@ -154,6 +157,10 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi return expiringMessageManager; } + public RevealableMessageManager getRevealableMessageManager() { + return revealableMessageManager; + } + public TypingStatusRepository getTypingStatusRepository() { return typingStatusRepository; } @@ -244,6 +251,10 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi this.expiringMessageManager = new ExpiringMessageManager(this); } + private void initializeRevealableMessageManager() { + this.revealableMessageManager = new RevealableMessageManager(this); + } + private void initializeTypingStatusRepository() { this.typingStatusRepository = new TypingStatusRepository(); } diff --git a/src/org/thoughtcrime/securesms/BindableConversationItem.java b/src/org/thoughtcrime/securesms/BindableConversationItem.java index ae11885fd7..fdded7b42c 100644 --- a/src/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/src/org/thoughtcrime/securesms/BindableConversationItem.java @@ -38,6 +38,7 @@ public interface BindableConversationItem extends Unbindable { void onLinkPreviewClicked(@NonNull LinkPreview linkPreview); void onMoreTextClicked(@NonNull Address conversationAddress, long messageId, boolean isMms); void onStickerClicked(@NonNull StickerLocator stickerLocator); + void onRevealableMessageClicked(@NonNull MmsMessageRecord messageRecord); void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView); void onAddToContactsClicked(@NonNull Contact contact); void onMessageSharedContactClicked(@NonNull List choices); diff --git a/src/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java b/src/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java new file mode 100644 index 0000000000..79feb548c3 --- /dev/null +++ b/src/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.attachments; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.AttachmentDatabase; + +public class TombstoneAttachment extends Attachment { + + public TombstoneAttachment(@NonNull String contentType, boolean quote) { + super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, null, null, null, null, null, false, 0, 0, quote, null, null); + } + + @Override + public @Nullable Uri getDataUri() { + return null; + } + + @Override + public @Nullable Uri getThumbnailUri() { + return null; + } +} diff --git a/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java index bd0864f065..c698a5dfd3 100644 --- a/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -75,8 +75,8 @@ public class FullBackupExporter extends FullBackupBase { int count = 0; for (String table : tables) { - if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count); + if (table.equals(MmsDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMessage, null, count); } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count); } else if (table.equals(AttachmentDatabase.TABLE_NAME)) { @@ -253,14 +253,20 @@ public class FullBackupExporter extends FullBackupBase { return result; } + private static boolean isNonExpiringMessage(@NonNull Cursor cursor) { + return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 && + cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.REVEAL_DURATION)) <= 0; + } + private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) { - String[] columns = new String[] { MmsDatabase.EXPIRES_IN }; + String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.REVEAL_DURATION }; String where = MmsDatabase.ID + " = ?"; String[] args = new String[] { String.valueOf(mmsId) }; try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) { if (mmsCursor != null && mmsCursor.moveToFirst()) { - return mmsCursor.getLong(0) == 0; + return mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)) == 0 && + mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.REVEAL_DURATION)) == 0; } } diff --git a/src/org/thoughtcrime/securesms/components/Outliner.java b/src/org/thoughtcrime/securesms/components/Outliner.java index 64a4bfbb86..1702612852 100644 --- a/src/org/thoughtcrime/securesms/components/Outliner.java +++ b/src/org/thoughtcrime/securesms/components/Outliner.java @@ -25,12 +25,16 @@ public class Outliner { } public void draw(Canvas canvas) { + draw(canvas, 0, canvas.getWidth(), canvas.getHeight(), 0); + } + + public void draw(Canvas canvas, int top, int right, int bottom, int left) { final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2; - bounds.left = halfStrokeWidth; - bounds.top = halfStrokeWidth; - bounds.right = canvas.getWidth() - halfStrokeWidth; - bounds.bottom = canvas.getHeight() - halfStrokeWidth; + bounds.left = left + halfStrokeWidth; + bounds.top = top + halfStrokeWidth; + bounds.right = right - halfStrokeWidth; + bounds.bottom = bottom - halfStrokeWidth; corners.reset(); corners.addRoundRect(bounds, radii, Path.Direction.CW); diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java index d55fc20db0..0c8d4486eb 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -92,6 +92,8 @@ import org.thoughtcrime.securesms.RegistrationActivity; import org.thoughtcrime.securesms.ShortcutLauncherActivity; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.TombstoneAttachment; import org.thoughtcrime.securesms.audio.AudioRecorder; import org.thoughtcrime.securesms.audio.AudioSlidePlayer; import org.thoughtcrime.securesms.color.MaterialColor; @@ -539,6 +541,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity boolean initiating = threadId == -1; TransportOption transport = data.getParcelableExtra(MediaSendActivity.EXTRA_TRANSPORT); String message = data.getStringExtra(MediaSendActivity.EXTRA_MESSAGE); + long revealDuration = data.getLongExtra(MediaSendActivity.EXTRA_REVEAL_DURATION, 0); + QuoteModel quote = (revealDuration == 0) ? inputPanel.getQuote().orNull() : null; SlideDeck slideDeck = new SlideDeck(); if (transport == null) { @@ -566,10 +570,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity sendMediaMessage(transport.isSms(), message, slideDeck, - inputPanel.getQuote().orNull(), + quote, Collections.emptyList(), Collections.emptyList(), expiresIn, + revealDuration, subscriptionId, initiating, true).addListener(new AssertedSuccessListener() { @@ -1807,7 +1812,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity long expiresIn = recipient.getExpireMessages() * 1000L; boolean initiating = threadId == -1; - sendMediaMessage(isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), expiresIn, subscriptionId, initiating, false); + sendMediaMessage(isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), expiresIn, 0, subscriptionId, initiating, false); } private void selectContactInfo(ContactData contactData) { @@ -2129,7 +2134,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } else if (!forceSms && identityRecords.isUntrusted()) { handleUntrustedRecipients(); } else if (isMediaMessage) { - sendMediaMessage(forceSms, expiresIn, subscriptionId, initiating); + sendMediaMessage(forceSms, expiresIn, 0, subscriptionId, initiating); } else { sendTextMessage(forceSms, expiresIn, subscriptionId, initiating); } @@ -2145,11 +2150,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } } - private void sendMediaMessage(final boolean forceSms, final long expiresIn, final int subscriptionId, boolean initiating) + private void sendMediaMessage(final boolean forceSms, final long expiresIn, final long revealDuration, final int subscriptionId, boolean initiating) throws InvalidMessageException { Log.i(TAG, "Sending media message..."); - sendMediaMessage(forceSms, getMessage(), attachmentManager.buildSlideDeck(), inputPanel.getQuote().orNull(), Collections.emptyList(), linkPreviewViewModel.getActiveLinkPreviews(), expiresIn, subscriptionId, initiating, true); + sendMediaMessage(forceSms, getMessage(), attachmentManager.buildSlideDeck(), inputPanel.getQuote().orNull(), Collections.emptyList(), linkPreviewViewModel.getActiveLinkPreviews(), expiresIn, revealDuration, subscriptionId, initiating, true); } private ListenableFuture sendMediaMessage(final boolean forceSms, @@ -2159,6 +2164,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity List contacts, List previews, final long expiresIn, + final long revealDuration, final int subscriptionId, final boolean initiating, final boolean clearComposeBox) @@ -2177,7 +2183,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } } - OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient, slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, distributionType, quote, contacts, previews); + OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient, slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, revealDuration, distributionType, quote, contacts, previews); final SettableFuture future = new SettableFuture<>(); final Context context = getApplicationContext(); @@ -2378,7 +2384,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity SlideDeck slideDeck = new SlideDeck(); slideDeck.addSlide(audioSlide); - sendMediaMessage(forceSms, "", slideDeck, inputPanel.getQuote().orNull(), Collections.emptyList(), Collections.emptyList(), expiresIn, subscriptionId, initiating, true).addListener(new AssertedSuccessListener() { + sendMediaMessage(forceSms, "", slideDeck, inputPanel.getQuote().orNull(), Collections.emptyList(), Collections.emptyList(), expiresIn, 0, subscriptionId, initiating, true).addListener(new AssertedSuccessListener() { @Override public void onSuccess(Void nothing) { new AsyncTask() { @@ -2506,7 +2512,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity slideDeck.addSlide(stickerSlide); - sendMediaMessage(transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), expiresIn, subscriptionId, initiating, clearCompose); + sendMediaMessage(transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), expiresIn, 0, subscriptionId, initiating, clearCompose); } @@ -2687,11 +2693,19 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity messageRecord.getBody(), slideDeck); } else { + SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck(); + + if (messageRecord.isMms() && ((MmsMessageRecord) messageRecord).getRevealDuration() > 0 && slideDeck.getSlides().size() > 0) { + Attachment attachment = new TombstoneAttachment(slideDeck.getSlides().get(0).getContentType(), true); + slideDeck = new SlideDeck(); + slideDeck.addSlide(MediaUtil.getSlideForAttachment(this, attachment)); + } + inputPanel.setQuote(GlideApp.with(this), messageRecord.getDateSent(), author, messageRecord.getBody(), - messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck()); + slideDeck); } } diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java index db04cbfae1..4ed11fed3c 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -71,6 +71,7 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter.HeaderViewHol import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener; import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessagingDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.loaders.ConversationLoader; @@ -78,6 +79,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceRevealUpdateJob; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.longmessage.LongMessageActivity; @@ -88,6 +90,8 @@ import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.profiles.UnknownSenderView; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.revealable.RevealableMessageActivity; +import org.thoughtcrime.securesms.revealable.RevealableUtil; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.stickers.StickerLocator; @@ -958,6 +962,38 @@ public class ConversationFragment extends Fragment } } + @Override + public void onRevealableMessageClicked(@NonNull MmsMessageRecord messageRecord) { + if (messageRecord.getRevealDuration() == 0) { + throw new AssertionError("Non-revealable message clicked."); + } + + if (messageRecord.getRevealStartTime() == 0) { + SimpleTask.run(getLifecycle(), () -> { + if (!messageRecord.isOutgoing()) { + Log.i(TAG, "Marking revealable message as opened."); + + DatabaseFactory.getMmsDatabase(requireContext()).markRevealStarted(messageRecord.getId()); + + ApplicationContext.getInstance(requireContext()) + .getRevealableMessageManager() + .scheduleIfNecessary(); + + ApplicationContext.getInstance(requireContext()) + .getJobManager() + .add(new MultiDeviceRevealUpdateJob(new MessagingDatabase.SyncMessageId(messageRecord.getIndividualRecipient().getAddress(), messageRecord.getDateSent()))); + } else { + Log.i(TAG, "Opening your own revealable message. It will automatically be marked as opened when it is sent."); + } + return null; + }, (nothing) -> { + startActivity(RevealableMessageActivity.getIntent(requireContext(), messageRecord.getId())); + }); + } else if (RevealableUtil.isViewable(messageRecord)) { + startActivity(RevealableMessageActivity.getIntent(requireContext(), messageRecord.getId())); + } + } + @Override public void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView) { if (getContext() != null && getActivity() != null) { diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java index 141ca4be31..ee8b44c974 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -21,6 +21,7 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.res.TypedArray; +import android.graphics.Canvas; import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.Typeface; @@ -65,7 +66,9 @@ import org.thoughtcrime.securesms.components.ConversationItemFooter; import org.thoughtcrime.securesms.components.ConversationItemThumbnail; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.LinkPreviewView; +import org.thoughtcrime.securesms.components.Outliner; import org.thoughtcrime.securesms.components.QuoteView; +import org.thoughtcrime.securesms.revealable.RevealableMessageView; import org.thoughtcrime.securesms.components.SharedContactView; import org.thoughtcrime.securesms.components.StickerView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; @@ -96,6 +99,7 @@ import org.thoughtcrime.securesms.mms.SlidesClickedListener; import org.thoughtcrime.securesms.mms.TextSlide; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientModifiedListener; +import org.thoughtcrime.securesms.revealable.RevealableUtil; import org.thoughtcrime.securesms.stickers.StickerUrl; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.DynamicTheme; @@ -151,6 +155,7 @@ public class ConversationItem extends LinearLayout private ViewGroup container; private @NonNull Set batchSelected = new HashSet<>(); + private @NonNull Outliner outliner = new Outliner(); private Recipient conversationRecipient; private Stub mediaThumbnailStub; private Stub audioViewStub; @@ -158,6 +163,7 @@ public class ConversationItem extends LinearLayout private Stub sharedContactStub; private Stub linkPreviewStub; private Stub stickerStub; + private Stub revealableStub; private @Nullable EventListener eventListener; private int defaultBubbleColor; @@ -169,6 +175,7 @@ public class ConversationItem extends LinearLayout private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener(); private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener(); private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener(); + private final RevealableMessageClickListener revealableClickListener = new RevealableMessageClickListener(); private final Context context; @@ -207,6 +214,7 @@ public class ConversationItem extends LinearLayout this.sharedContactStub = new Stub<>(findViewById(R.id.shared_contact_view_stub)); this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub)); this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub)); + this.revealableStub = new Stub<>(findViewById(R.id.revealable_view_stub)); this.groupSenderHolder = findViewById(R.id.group_sender_holder); this.quoteView = findViewById(R.id.quote_view); this.container = findViewById(R.id.container); @@ -302,11 +310,21 @@ public class ConversationItem extends LinearLayout } } + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + + if (!messageRecord.isOutgoing() && hasRevealableMessage(messageRecord) && RevealableUtil.isRevealExpired((MmsMessageRecord) messageRecord)) { + outliner.setColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_text_secondary_color)); + outliner.draw(canvas, bodyBubble.getTop() + getPaddingTop(), bodyBubble.getRight(), bodyBubble.getBottom() + getPaddingTop(), bodyBubble.getLeft()); + } + } + private int getAvailableMessageBubbleWidth(@NonNull View forView) { int availableWidth; if (hasAudio(messageRecord)) { availableWidth = audioViewStub.get().getMeasuredWidth() + ViewUtil.getLeftMargin(audioViewStub.get()) + ViewUtil.getRightMargin(audioViewStub.get()); - } else if (hasThumbnail(messageRecord) || hasBigImageLinkPreview(messageRecord)) { + } else if (!hasRevealableMessage(messageRecord) && (hasThumbnail(messageRecord) || hasBigImageLinkPreview(messageRecord))) { availableWidth = mediaThumbnailStub.get().getMeasuredWidth(); } else { availableWidth = bodyBubble.getMeasuredWidth() - bodyBubble.getPaddingLeft() - bodyBubble.getPaddingRight(); @@ -341,8 +359,16 @@ public class ConversationItem extends LinearLayout private void setBubbleState(MessageRecord messageRecord) { if (messageRecord.isOutgoing()) { bodyBubble.getBackground().setColorFilter(defaultBubbleColor, PorterDuff.Mode.MULTIPLY); + footer.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_text_secondary_color)); + footer.setIconColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_icon_color)); + } else if (hasRevealableMessage(messageRecord) && RevealableUtil.isRevealExpired((MmsMessageRecord) messageRecord)) { + bodyBubble.getBackground().setColorFilter(ThemeUtil.getThemedColor(context, R.attr.conversation_item_reveal_viewed_background_color), PorterDuff.Mode.MULTIPLY); + footer.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_text_secondary_color)); + footer.setIconColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_icon_color)); } else { bodyBubble.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY); + footer.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_secondary_color)); + footer.setIconColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_secondary_color)); } if (audioViewStub.resolved()) { @@ -413,7 +439,8 @@ public class ConversationItem extends LinearLayout !hasAudio(messageRecord) && !hasDocument(messageRecord) && !hasSharedContact(messageRecord) && - !hasSticker(messageRecord); + !hasSticker(messageRecord) && + !hasRevealableMessage(messageRecord); } private boolean hasDocument(MessageRecord messageRecord) { @@ -450,6 +477,10 @@ public class ConversationItem extends LinearLayout !StickerUrl.isValidShareLink(linkPreview.getUrl()); } + private boolean hasRevealableMessage(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord) messageRecord).getRevealDuration() > 0; + } + private void setBodyText(MessageRecord messageRecord, @Nullable String searchQuery) { bodyText.setClickable(false); bodyText.setFocusable(false); @@ -481,13 +512,28 @@ public class ConversationItem extends LinearLayout { boolean showControls = !messageRecord.isFailed(); - if (hasSharedContact(messageRecord)) { + if (hasRevealableMessage(messageRecord)) { + revealableStub.get().setVisibility(VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + + revealableStub.get().setMessage((MmsMessageRecord) messageRecord); + revealableStub.get().setOnClickListener(revealableClickListener); + revealableStub.get().setOnLongClickListener(passthroughClickListener); + + footer.setVisibility(VISIBLE); + } else if (hasSharedContact(messageRecord)) { sharedContactStub.get().setVisibility(VISIBLE); if (audioViewStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale); sharedContactStub.get().setEventListener(sharedContactEventListener); @@ -506,6 +552,7 @@ public class ConversationItem extends LinearLayout if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); //noinspection ConstantConditions LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0); @@ -544,6 +591,7 @@ public class ConversationItem extends LinearLayout if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); //noinspection ConstantConditions audioViewStub.get().setAudio(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide(), showControls); @@ -561,6 +609,7 @@ public class ConversationItem extends LinearLayout if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); //noinspection ConstantConditions documentViewStub.get().setDocument(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(), showControls); @@ -581,6 +630,7 @@ public class ConversationItem extends LinearLayout if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); //noinspection ConstantConditions stickerStub.get().setSticker(glideRequests, ((MmsMessageRecord) messageRecord).getSlideDeck().getStickerSlide()); @@ -600,6 +650,7 @@ public class ConversationItem extends LinearLayout if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); //noinspection ConstantConditions List thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides(); @@ -629,6 +680,7 @@ public class ConversationItem extends LinearLayout if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -876,7 +928,10 @@ public class ConversationItem extends LinearLayout } private void setGroupAuthorColor(@NonNull MessageRecord messageRecord) { - if (hasSticker(messageRecord)) { + if (!messageRecord.isOutgoing() && hasRevealableMessage(messageRecord) && RevealableUtil.isRevealExpired((MmsMessageRecord) messageRecord)) { + groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color)); + groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color)); + } else if (hasSticker(messageRecord)) { groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color)); groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color)); } else { @@ -912,19 +967,43 @@ public class ConversationItem extends LinearLayout } private void setMessageShape(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread) { + int bigRadius = readDimen(R.dimen.message_corner_radius); + int smallRadius = readDimen(R.dimen.message_corner_collapse_radius); + int background; + if (isSingularMessage(current, previous, next, isGroupThread)) { - background = current.isOutgoing() ? R.drawable.message_bubble_background_sent_alone - : R.drawable.message_bubble_background_received_alone; + if (current.isOutgoing()) { + background = R.drawable.message_bubble_background_sent_alone; + outliner.setRadius(bigRadius); + } else { + background = R.drawable.message_bubble_background_received_alone; + outliner.setRadius(bigRadius); + } } else if (isStartOfMessageCluster(current, previous, isGroupThread)) { - background = current.isOutgoing() ? R.drawable.message_bubble_background_sent_start - : R.drawable.message_bubble_background_received_start; + if (current.isOutgoing()) { + background = R.drawable.message_bubble_background_sent_start; + outliner.setRadii(bigRadius, bigRadius, smallRadius, bigRadius); + } else { + background = R.drawable.message_bubble_background_received_start; + outliner.setRadii(bigRadius, bigRadius, bigRadius, smallRadius); + } } else if (isEndOfMessageCluster(current, next, isGroupThread)) { - background = current.isOutgoing() ? R.drawable.message_bubble_background_sent_end - : R.drawable.message_bubble_background_received_end; + if (current.isOutgoing()) { + background = R.drawable.message_bubble_background_sent_end; + outliner.setRadii(bigRadius, smallRadius, bigRadius, bigRadius); + } else { + background = R.drawable.message_bubble_background_received_end; + outliner.setRadii(smallRadius, bigRadius, bigRadius, bigRadius); + } } else { - background = current.isOutgoing() ? R.drawable.message_bubble_background_sent_middle - : R.drawable.message_bubble_background_received_middle; + if (current.isOutgoing()) { + background = R.drawable.message_bubble_background_sent_middle; + outliner.setRadii(bigRadius, smallRadius, smallRadius, bigRadius); + } else { + background = R.drawable.message_bubble_background_received_middle; + outliner.setRadii(smallRadius, bigRadius, bigRadius, smallRadius); + } } bodyBubble.setBackgroundResource(background); @@ -1090,6 +1169,21 @@ public class ConversationItem extends LinearLayout } } + private class RevealableMessageClickListener implements View.OnClickListener { + @Override + public void onClick(View view) { + RevealableMessageView revealView = (RevealableMessageView) view; + + if (eventListener != null && batchSelected.isEmpty() && messageRecord.isMms() && RevealableUtil.isViewable((MmsMessageRecord) messageRecord)) { + eventListener.onRevealableMessageClicked((MmsMessageRecord) messageRecord); + } else if (batchSelected.isEmpty() && messageRecord.isMms() && revealView.requiresTapToDownload((MmsMessageRecord) messageRecord)) { + singleDownloadClickListener.onClick(view, ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlide()); + } 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()) { diff --git a/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java index d5e46f6888..f3e6995f0e 100644 --- a/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -245,6 +245,15 @@ public class AttachmentDatabase extends Database { } } + public boolean hasAttachmentFilesForMessage(long mmsId) { + String selection = MMS_ID + " = ? AND (" + DATA + " NOT NULL OR " + TRANSFER_STATE + " != ?)"; + String[] args = new String[] { String.valueOf(mmsId), String.valueOf(TRANSFER_PROGRESS_DONE) }; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, "1")) { + return cursor != null && cursor.moveToFirst(); + } + } + public @NonNull List getPendingAttachments() { final SQLiteDatabase database = databaseHelper.getReadableDatabase(); final List attachments = new LinkedList<>(); @@ -263,7 +272,7 @@ public class AttachmentDatabase extends Database { } @SuppressWarnings("ResultOfMethodCallIgnored") - void deleteAttachmentsForMessage(long mmsId) { + public void deleteAttachmentsForMessage(long mmsId) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); Cursor cursor = null; @@ -283,6 +292,44 @@ public class AttachmentDatabase extends Database { notifyAttachmentListeners(); } + public void deleteAttachmentFilesForMessage(long mmsId) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, new String[] {DATA, THUMBNAIL, CONTENT_TYPE}, MMS_ID + " = ?", + new String[] {mmsId+""}, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + deleteAttachmentOnDisk(cursor.getString(0), cursor.getString(1), cursor.getString(2)); + } + } finally { + if (cursor != null) + cursor.close(); + } + + ContentValues values = new ContentValues(); + values.put(DATA, (String) null); + values.put(DATA_RANDOM, (byte[]) null); + values.put(THUMBNAIL, (String) null); + values.put(THUMBNAIL_RANDOM, (byte[]) null); + values.put(FILE_NAME, (String) null); + values.put(CAPTION, (String) null); + values.put(SIZE, 0); + values.put(WIDTH, 0); + values.put(HEIGHT, 0); + values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE); + + database.update(TABLE_NAME, values, MMS_ID + " = ?", new String[] {mmsId + ""}); + notifyAttachmentListeners(); + + long threadId = DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(mmsId); + if (threadId > 0) { + notifyConversationListeners(threadId); + } + } + + public void deleteAttachment(@NonNull AttachmentId id) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); diff --git a/src/org/thoughtcrime/securesms/database/MediaDatabase.java b/src/org/thoughtcrime/securesms/database/MediaDatabase.java index 523a168c57..d169f26568 100644 --- a/src/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -47,6 +47,7 @@ public class MediaDatabase extends Database { + "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID + " FROM " + MmsDatabase.TABLE_NAME + " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND " + + MmsDatabase.REVEAL_DURATION + " = 0 AND " + AttachmentDatabase.DATA + " IS NOT NULL AND " + AttachmentDatabase.QUOTE + " = 0 AND " + AttachmentDatabase.STICKER_PACK_ID + " IS NULL " diff --git a/src/org/thoughtcrime/securesms/database/MessagingDatabase.java b/src/org/thoughtcrime/securesms/database/MessagingDatabase.java index 349ad48255..23bb816ac2 100644 --- a/src/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -5,15 +5,19 @@ import android.content.Context; import android.database.Cursor; import android.text.TextUtils; +import androidx.annotation.NonNull; + import net.sqlcipher.database.SQLiteDatabase; import org.thoughtcrime.securesms.database.documents.Document; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.JsonUtils; import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; import java.util.ArrayList; diff --git a/src/org/thoughtcrime/securesms/database/MmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsDatabase.java index c2870723b3..4886c768c7 100644 --- a/src/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -63,6 +63,8 @@ import org.thoughtcrime.securesms.mms.QuoteModel; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientFormattingException; +import org.thoughtcrime.securesms.revealable.RevealExpirationInfo; +import org.thoughtcrime.securesms.revealable.RevealableUtil; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -107,6 +109,9 @@ public class MmsDatabase extends MessagingDatabase { static final String SHARED_CONTACTS = "shared_contacts"; static final String LINK_PREVIEWS = "previews"; + public static final String REVEAL_DURATION = "reveal_duration"; + public static final String REVEAL_START_TIME = "reveal_start_time"; + 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, " + READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " + @@ -126,7 +131,7 @@ public class MmsDatabase extends MessagingDatabase { READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + QUOTE_ID + " INTEGER DEFAULT 0, " + QUOTE_AUTHOR + " TEXT, " + QUOTE_BODY + " TEXT, " + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " + QUOTE_MISSING + " INTEGER DEFAULT 0, " + SHARED_CONTACTS + " TEXT, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " + - LINK_PREVIEWS + " TEXT);"; + LINK_PREVIEWS + " TEXT, " + REVEAL_DURATION + " INTEGER DEFAULT 0, " + REVEAL_START_TIME + " INTEGER DEFAULT 0);"; public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", @@ -147,7 +152,7 @@ public class MmsDatabase extends MessagingDatabase { BODY, PART_COUNT, ADDRESS, ADDRESS_DEVICE_ID, DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, - SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, + SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, REVEAL_DURATION, REVEAL_START_TIME, "json_group_array(json_object(" + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + @@ -432,6 +437,60 @@ public class MmsDatabase extends MessagingDatabase { notifyConversationListeners(threadId); } + public void markRevealStarted(long messageId) { + markRevealStarted(messageId, System.currentTimeMillis()); + } + + public void markRevealStarted(long messageId, long startTime) { + ContentValues contentValues = new ContentValues(); + contentValues.put(REVEAL_START_TIME, startTime); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); + + long threadId = getThreadIdForMessage(messageId); + notifyConversationListeners(threadId); + } + + public List markRevealStarted(@NonNull SyncMessageId messageId, long proposedStartTime) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + List expirationInfos = new LinkedList<>(); + + String[] projection = new String[] { ID, ADDRESS, THREAD_ID, DATE_SENT, DATE_RECEIVED, REVEAL_DURATION, REVEAL_START_TIME }; + String selection = DATE_SENT + " = ?"; + String[] args = new String[] { String.valueOf(messageId.getTimetamp()) }; + + try (Cursor cursor = db.query(TABLE_NAME, projection, selection, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + Address theirAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))); + Address ourAddress = messageId.getAddress(); + + if (ourAddress.equals(theirAddress) || theirAddress.isGroup()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); + long receiveTime = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED)); + long revealDuration = cursor.getLong(cursor.getColumnIndexOrThrow(REVEAL_DURATION)); + long revealStartTime = cursor.getLong(cursor.getColumnIndexOrThrow(REVEAL_START_TIME)); + + revealStartTime = revealStartTime > 0 ? Math.min(proposedStartTime, revealStartTime) : proposedStartTime; + revealStartTime = Math.min(revealStartTime, System.currentTimeMillis()); + + ContentValues values = new ContentValues(); + + values.put(REVEAL_START_TIME, revealStartTime); + expirationInfos.add(new RevealExpirationInfo(id, receiveTime, revealStartTime, revealDuration)); + + db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(id) }); + + DatabaseFactory.getThreadDatabase(context).setLastSeen(threadId); + notifyConversationListeners(threadId); + } + } + } + + return expirationInfos; + } + public void markAsNotified(long id) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); ContentValues contentValues = new ContentValues(); @@ -609,6 +668,7 @@ public class MmsDatabase extends MessagingDatabase { long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)); int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)); long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)); + long revealDuration = cursor.getLong(cursor.getColumnIndexOrThrow(REVEAL_DURATION)); String address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)); long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); int distributionType = DatabaseFactory.getThreadDatabase(context).getDistributionType(threadId); @@ -655,12 +715,12 @@ public class MmsDatabase extends MessagingDatabase { } if (body != null && (Types.isGroupQuit(outboxType) || Types.isGroupUpdate(outboxType))) { - return new OutgoingGroupMediaMessage(recipient, body, attachments, timestamp, 0, quote, contacts, previews); + return new OutgoingGroupMediaMessage(recipient, body, attachments, timestamp, 0, 0, quote, contacts, previews); } else if (Types.isExpirationTimerUpdate(outboxType)) { return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn); } - OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, distributionType, quote, contacts, previews, networkFailures, mismatches); + OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, revealDuration, distributionType, quote, contacts, previews, networkFailures, mismatches); if (Types.isSecureType(outboxType)) { return new OutgoingSecureMediaMessage(message); @@ -764,6 +824,7 @@ public class MmsDatabase extends MessagingDatabase { contentValues.put(READ, 1); contentValues.put(DATE_RECEIVED, contentValues.getAsLong(DATE_SENT)); contentValues.put(EXPIRES_IN, request.getExpiresIn()); + contentValues.put(REVEAL_DURATION, request.getRevealDuration()); List attachments = new LinkedList<>(); @@ -831,6 +892,7 @@ public class MmsDatabase extends MessagingDatabase { contentValues.put(PART_COUNT, retrieved.getAttachments().size()); contentValues.put(SUBSCRIPTION_ID, retrieved.getSubscriptionId()); contentValues.put(EXPIRES_IN, retrieved.getExpiresIn()); + contentValues.put(REVEAL_DURATION, retrieved.getRevealDuration()); contentValues.put(READ, retrieved.isExpirationUpdate() ? 1 : 0); contentValues.put(UNIDENTIFIED, retrieved.isUnidentified()); @@ -986,6 +1048,7 @@ public class MmsDatabase extends MessagingDatabase { contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId()); contentValues.put(EXPIRES_IN, message.getExpiresIn()); + contentValues.put(REVEAL_DURATION, message.getRevealDuration()); contentValues.put(ADDRESS, message.getRecipient().getAddress().serialize()); contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum()); contentValues.put(READ_RECEIPT_COUNT, Stream.of(earlyReadReceipts.values()).mapToLong(Long::longValue).sum()); @@ -1244,6 +1307,42 @@ public class MmsDatabase extends MessagingDatabase { database.delete(TABLE_NAME, null, null); } + public @Nullable RevealExpirationInfo getNearestExpiringRevealableMessage() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + RevealExpirationInfo info = null; + long nearestExpiration = Long.MAX_VALUE; + + String query = "SELECT " + + TABLE_NAME + "." + ID + ", " + + REVEAL_DURATION + ", " + + REVEAL_START_TIME + ", " + + DATE_RECEIVED + " " + + "FROM " + TABLE_NAME + " INNER JOIN " + AttachmentDatabase.TABLE_NAME + " " + + "ON " + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " " + + "WHERE " + + REVEAL_DURATION + " > 0 AND " + + "(" + AttachmentDatabase.DATA + " NOT NULL OR " + AttachmentDatabase.TRANSFER_STATE + " != ?)"; + String[] args = new String[] { String.valueOf(AttachmentDatabase.TRANSFER_PROGRESS_DONE) }; + + try (Cursor cursor = db.rawQuery(query, args)) { + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + long revealDuration = cursor.getLong(cursor.getColumnIndexOrThrow(REVEAL_DURATION)); + long revealStartTime = cursor.getLong(cursor.getColumnIndexOrThrow(REVEAL_START_TIME)); + long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED)); + long expiresAt = revealStartTime > 0 ? revealStartTime + revealDuration + : dateReceived + RevealableUtil.MAX_LIFESPAN; + + if (info == null || expiresAt < nearestExpiration) { + info = new RevealExpirationInfo(id, dateReceived, revealStartTime, revealDuration); + nearestExpiration = expiresAt; + } + } + } + + return info; + } + public Cursor getCarrierMmsInformation(String apn) { Uri uri = Uri.withAppendedPath(Uri.parse("content://telephony/carriers"), "current"); String selection = TextUtils.isEmpty(apn) ? null : "apn = ?"; @@ -1342,7 +1441,10 @@ public class MmsDatabase extends MessagingDatabase { new LinkedList(), message.getSubscriptionId(), message.getExpiresIn(), - System.currentTimeMillis(), 0, + System.currentTimeMillis(), + message.getRevealDuration(), + 0, + 0, message.getOutgoingQuote() != null ? new Quote(message.getOutgoingQuote().getId(), message.getOutgoingQuote().getAuthor(), @@ -1439,6 +1541,8 @@ public class MmsDatabase extends MessagingDatabase { long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)); long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRE_STARTED)); boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1; + long revealDuration = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REVEAL_DURATION)); + long revealStartTime = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REVEAL_START_TIME)); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -1459,6 +1563,7 @@ public class MmsDatabase extends MessagingDatabase { addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId, body, slideDeck, partCount, box, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, + revealDuration, revealStartTime, readReceiptCount, quote, contacts, previews, unidentified); } diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 118989623e..7053d3bd93 100644 --- a/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -32,6 +32,7 @@ public interface MmsSmsColumns { protected static final long MISSED_CALL_TYPE = 3; protected static final long JOINED_TYPE = 4; protected static final long UNSUPPORTED_MESSAGE_TYPE = 5; + protected static final long INVALID_MESSAGE_TYPE = 6; protected static final long BASE_INBOX_TYPE = 20; protected static final long BASE_OUTBOX_TYPE = 21; @@ -147,6 +148,10 @@ public interface MmsSmsColumns { return (type & BASE_TYPE_MASK) == UNSUPPORTED_MESSAGE_TYPE; } + public static boolean isInvalidMessageType(long type) { + return (type & BASE_TYPE_MASK) == INVALID_MESSAGE_TYPE; + } + public static boolean isSecureType(long type) { return (type & SECURE_MESSAGE_BIT) != 0; } diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 1d6fe88903..dfa9614d98 100644 --- a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -70,7 +70,9 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS}; + MmsDatabase.LINK_PREVIEWS, + MmsDatabase.REVEAL_DURATION, + MmsDatabase.REVEAL_START_TIME}; public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); @@ -270,7 +272,9 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS}; + MmsDatabase.LINK_PREVIEWS, + MmsDatabase.REVEAL_DURATION, + MmsDatabase.REVEAL_START_TIME}; String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, @@ -296,7 +300,9 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS}; + MmsDatabase.LINK_PREVIEWS, + MmsDatabase.REVEAL_DURATION, + MmsDatabase.REVEAL_START_TIME}; SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); @@ -367,6 +373,8 @@ public class MmsSmsDatabase extends Database { mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT); mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS); + mmsColumnsPresent.add(MmsDatabase.REVEAL_DURATION); + mmsColumnsPresent.add(MmsDatabase.REVEAL_START_TIME); Set smsColumnsPresent = new HashSet<>(); smsColumnsPresent.add(MmsSmsColumns.ID); diff --git a/src/org/thoughtcrime/securesms/database/SmsDatabase.java b/src/org/thoughtcrime/securesms/database/SmsDatabase.java index f42237571c..81527845b5 100644 --- a/src/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -221,6 +221,10 @@ public class SmsDatabase extends MessagingDatabase { updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.UNSUPPORTED_MESSAGE_TYPE); } + public void markAsInvalidMessage(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.INVALID_MESSAGE_TYPE); + } + public void markAsLegacyVersion(long id) { updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_LEGACY_BIT); } @@ -883,7 +887,8 @@ public class SmsDatabase extends MessagingDatabase { addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, type, threadId, status, mismatches, subscriptionId, - expiresIn, expireStarted, readReceiptCount, unidentified); + expiresIn, expireStarted, + readReceiptCount, unidentified); } private List getMismatches(String document) { diff --git a/src/org/thoughtcrime/securesms/database/ThreadDatabase.java b/src/org/thoughtcrime/securesms/database/ThreadDatabase.java index 0bd67c024e..89ee2cf2d6 100644 --- a/src/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/src/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -26,6 +26,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.annimon.stream.Stream; +import com.fasterxml.jackson.annotation.JsonProperty; import net.sqlcipher.database.SQLiteDatabase; @@ -44,12 +45,15 @@ import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.DelimiterUtil; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; import java.io.Closeable; +import java.io.IOException; import java.util.LinkedList; import java.util.List; import java.util.Set; @@ -71,6 +75,8 @@ public class ThreadDatabase extends Database { private static final String ERROR = "error"; public static final String SNIPPET_TYPE = "snippet_type"; public static final String SNIPPET_URI = "snippet_uri"; + public static final String SNIPPET_CONTENT_TYPE = "snippet_content_type"; + public static final String SNIPPET_EXTRAS = "snippet_extras"; public static final String ARCHIVED = "archived"; public static final String STATUS = "status"; public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; @@ -85,6 +91,7 @@ public class ThreadDatabase extends Database { SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + READ + " INTEGER DEFAULT 1, " + TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " + SNIPPET_TYPE + " INTEGER DEFAULT 0, " + SNIPPET_URI + " TEXT DEFAULT NULL, " + + SNIPPET_CONTENT_TYPE + " TEXT DEFAULT NULL, " + SNIPPET_EXTRAS + " TEXT DEFAULT NULL, " + ARCHIVED + " INTEGER DEFAULT 0, " + STATUS + " INTEGER DEFAULT 0, " + DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + EXPIRES_IN + " INTEGER DEFAULT 0, " + LAST_SEEN + " INTEGER DEFAULT 0, " + HAS_SENT + " INTEGER DEFAULT 0, " + @@ -97,7 +104,7 @@ public class ThreadDatabase extends Database { private static final String[] THREAD_PROJECTION = { ID, DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, TYPE, ERROR, SNIPPET_TYPE, - SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT + SNIPPET_URI, SNIPPET_CONTENT_TYPE, SNIPPET_EXTRAS, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT }; private static final List TYPED_THREAD_PROJECTION = Stream.of(THREAD_PROJECTION) @@ -130,15 +137,28 @@ public class ThreadDatabase extends Database { } private void updateThread(long threadId, long count, String body, @Nullable Uri attachment, + @Nullable String contentType, @Nullable Extra extra, long date, int status, int deliveryReceiptCount, long type, boolean unarchive, long expiresIn, int readReceiptCount) { + String extraSerialized = null; + + if (extra != null) { + try { + extraSerialized = JsonUtils.toJson(extra); + } catch (IOException e) { + throw new AssertionError(e); + } + } + ContentValues contentValues = new ContentValues(7); contentValues.put(DATE, date - date % 1000); contentValues.put(MESSAGE_COUNT, count); contentValues.put(SNIPPET, body); contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString()); contentValues.put(SNIPPET_TYPE, type); + contentValues.put(SNIPPET_CONTENT_TYPE, contentType); + contentValues.put(SNIPPET_EXTRAS, extraSerialized); contentValues.put(STATUS, status); contentValues.put(DELIVERY_RECEIPT_COUNT, deliveryReceiptCount); contentValues.put(READ_RECEIPT_COUNT, readReceiptCount); @@ -571,6 +591,7 @@ public class ThreadDatabase extends Database { if (reader != null && (record = reader.getNext()) != null) { updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), + getContentTypeFor(record), getExtrasFor(record), record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); notifyConversationListListeners(); @@ -601,13 +622,36 @@ public class ThreadDatabase extends Database { SlideDeck slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); Slide thumbnail = slideDeck.getThumbnailSlide(); - if (thumbnail != null) { + if (thumbnail != null && ((MmsMessageRecord) record).getRevealDuration() == 0) { return thumbnail.getThumbnailUri(); } return null; } + private @Nullable String getContentTypeFor(MessageRecord record) { + if (record.isMms()) { + SlideDeck slideDeck = ((MmsMessageRecord) record).getSlideDeck(); + + if (slideDeck.getSlides().size() > 0) { + return slideDeck.getSlides().get(0).getContentType(); + } + } + + return null; + } + + private @Nullable Extra getExtrasFor(MessageRecord record) { + if (record.isMms() && ((MmsMessageRecord) record).getRevealDuration() > 0) { + return Extra.forRevealableMessage(); + } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { + return Extra.forSticker(); + } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getSlides().size() > 1) { + return Extra.forAlbum(); + } + return null; + } + private @NonNull String createQuery(@NonNull String where, int limit) { String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ","); String query = @@ -686,12 +730,24 @@ public class ThreadDatabase extends Database { long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN)); long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)); Uri snippetUri = getSnippetUri(cursor); + String contentType = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_CONTENT_TYPE)); + String extraString = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_EXTRAS)); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; } - return new ThreadRecord(body, snippetUri, recipient, date, count, + Extra extra = null; + + if (extraString != null) { + try { + extra = JsonUtils.fromJson(extraString, Extra.class); + } catch (IOException e) { + Log.w(TAG, "Failed to decode extras!"); + } + } + + return new ThreadRecord(body, snippetUri, contentType, extra, recipient, date, count, unreadCount, threadId, deliveryReceiptCount, status, type, distributionType, archived, expiresIn, lastSeen, readReceiptCount); } @@ -716,4 +772,45 @@ public class ThreadDatabase extends Database { } } } + + public static final class Extra { + + @JsonProperty private final boolean isRevealable; + @JsonProperty private final boolean isSticker; + @JsonProperty private final boolean isAlbum; + + public Extra(@JsonProperty("isRevealable") boolean isRevealable, + @JsonProperty("isSticker") boolean isSticker, + @JsonProperty("isAlbum") boolean isAlbum) + { + this.isRevealable = isRevealable; + this.isSticker = isSticker; + this.isAlbum = isAlbum; + } + + public static @NonNull Extra forRevealableMessage() { + return new Extra(true, false, false); + } + + public static @NonNull Extra forSticker() { + return new Extra(false, true, false); + } + + public static @NonNull Extra forAlbum() { + return new Extra(false, false, true); + } + + + public boolean isRevealable() { + return isRevealable; + } + + public boolean isSticker() { + return isSticker; + } + + public boolean isAlbum() { + return isAlbum; + } + } } diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 678ec09a7d..67aafa94a8 100644 --- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -66,8 +66,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int RECIPIENT_FORCE_SMS_SELECTION = 19; private static final int JOBMANAGER_STRIKES_BACK = 20; private static final int STICKERS = 21; + private static final int REVEALABLE_MESSAGES = 22; - private static final int DATABASE_VERSION = 21; + private static final int DATABASE_VERSION = 22; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -462,6 +463,14 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("CREATE INDEX IF NOT EXISTS part_sticker_pack_id_index ON part (sticker_pack_id)"); } + if (oldVersion < REVEALABLE_MESSAGES) { + db.execSQL("ALTER TABLE mms ADD COLUMN reveal_duration INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN reveal_start_time INTEGER DEFAULT 0"); + + db.execSQL("ALTER TABLE thread ADD COLUMN snippet_content_type TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE thread ADD COLUMN snippet_extras TEXT DEFAULT NULL"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/src/org/thoughtcrime/securesms/database/loaders/ConversationListLoader.java b/src/org/thoughtcrime/securesms/database/loaders/ConversationListLoader.java index f2dd7081cf..c57e2b03c9 100644 --- a/src/org/thoughtcrime/securesms/database/loaders/ConversationListLoader.java +++ b/src/org/thoughtcrime/securesms/database/loaders/ConversationListLoader.java @@ -44,6 +44,7 @@ public class ConversationListLoader extends AbstractCursorLoader { ThreadDatabase.ID, ThreadDatabase.DATE, ThreadDatabase.MESSAGE_COUNT, ThreadDatabase.ADDRESS, ThreadDatabase.SNIPPET, ThreadDatabase.READ, ThreadDatabase.UNREAD_COUNT, ThreadDatabase.TYPE, ThreadDatabase.SNIPPET_TYPE, ThreadDatabase.SNIPPET_URI, + ThreadDatabase.SNIPPET_CONTENT_TYPE, ThreadDatabase.SNIPPET_EXTRAS, ThreadDatabase.ARCHIVED, ThreadDatabase.STATUS, ThreadDatabase.DELIVERY_RECEIPT_COUNT, ThreadDatabase.EXPIRES_IN, ThreadDatabase.LAST_SEEN, ThreadDatabase.READ_RECEIPT_COUNT}, 1); @@ -56,7 +57,7 @@ public class ConversationListLoader extends AbstractCursorLoader { switchToArchiveCursor.addRow(new Object[] {-1L, System.currentTimeMillis(), archivedCount, "-1", null, 1, 0, ThreadDatabase.DistributionTypes.ARCHIVE, - 0, null, 0, -1, 0, 0, 0, -1}); + 0, null, null, null, 0, -1, 0, 0, 0, -1}); cursorList.add(switchToArchiveCursor); } diff --git a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index cfe7d2acd8..3ad7384af9 100644 --- a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -54,14 +54,15 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { int partCount, long mailbox, List mismatches, List failures, int subscriptionId, - long expiresIn, long expireStarted, int readReceiptCount, + long expiresIn, long expireStarted, + long revealDuration, long revealStartTime, int readReceiptCount, @Nullable Quote quote, @NonNull List contacts, @NonNull List linkPreviews, boolean unidentified) { super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, - subscriptionId, expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts, - linkPreviews, unidentified); + subscriptionId, expiresIn, expireStarted, revealDuration, revealStartTime, slideDeck, + readReceiptCount, quote, contacts, linkPreviews, unidentified); this.partCount = partCount; } diff --git a/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 679c8f15f5..a4bf04975b 100644 --- a/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -22,19 +22,25 @@ public abstract class MmsMessageRecord extends MessageRecord { private final @NonNull List contacts = new LinkedList<>(); private final @NonNull List linkPreviews = new LinkedList<>(); + private final long revealDuration; + private final long revealStartTime; + MmsMessageRecord(long id, String body, Recipient conversationRecipient, Recipient individualRecipient, int recipientDeviceId, long dateSent, long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, long type, List mismatches, List networkFailures, int subscriptionId, long expiresIn, - long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, + long expireStarted, long revealDuration, long revealStartTime, + @NonNull SlideDeck slideDeck, int readReceiptCount, @Nullable Quote quote, @NonNull List contacts, @NonNull List linkPreviews, boolean unidentified) { super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified); - this.slideDeck = slideDeck; - this.quote = quote; + this.slideDeck = slideDeck; + this.quote = quote; + this.revealDuration = revealDuration; + this.revealStartTime = revealStartTime; this.contacts.addAll(contacts); this.linkPreviews.addAll(linkPreviews); @@ -76,4 +82,12 @@ public abstract class MmsMessageRecord extends MessageRecord { public @NonNull List getLinkPreviews() { return linkPreviews; } + + public long getRevealDuration() { + return revealDuration; + } + + public long getRevealStartTime() { + return revealStartTime; + } } diff --git a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java index 83212609bc..fbdafae288 100644 --- a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java @@ -57,7 +57,7 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord { super(id, "", conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, new LinkedList(), new LinkedList(), subscriptionId, - 0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false); + 0, 0, 0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false); this.contentLocation = contentLocation; this.messageSize = messageSize; diff --git a/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index 0b5e6801a9..09b16f1ae2 100644 --- a/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -86,6 +86,8 @@ public class SmsMessageRecord extends MessageRecord { return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset_s, getIndividualRecipient().toShortString())); } else if (SmsDatabase.Types.isUnsupportedMessageType(type)) { return emphasisAdded(context.getString(R.string.SmsMessageRecord_this_message_could_not_be_processed_because_it_was_sent_from_a_newer_version)); + } else if (SmsDatabase.Types.isInvalidMessageType(type)) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_error_handling_incoming_message)); } else { return super.getDisplayBody(context); } diff --git a/src/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/src/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 2ff72763ae..f735b20e19 100644 --- a/src/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -29,8 +29,11 @@ import android.text.style.StyleSpan; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase.Extra; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.MediaUtil; /** * The message record model which represents thread heading messages. @@ -41,6 +44,8 @@ import org.thoughtcrime.securesms.util.ExpirationUtil; public class ThreadRecord extends DisplayRecord { private @Nullable final Uri snippetUri; + private @Nullable final String contentType; + private @Nullable final Extra extra; private final long count; private final int unreadCount; private final int distributionType; @@ -49,6 +54,7 @@ public class ThreadRecord extends DisplayRecord { private final long lastSeen; public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, + @Nullable String contentType, @Nullable Extra extra, @NonNull Recipient recipient, long date, long count, int unreadCount, long threadId, int deliveryReceiptCount, int status, long snippetType, int distributionType, boolean archived, long expiresIn, long lastSeen, @@ -56,6 +62,8 @@ public class ThreadRecord extends DisplayRecord { { super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); this.snippetUri = snippetUri; + this.contentType = contentType; + this.extra = extra; this.count = count; this.unreadCount = unreadCount; this.distributionType = distributionType; @@ -113,7 +121,13 @@ public class ThreadRecord extends DisplayRecord { return emphasisAdded(context.getString(R.string.ThreadRecord_message_could_not_be_processed)); } else { if (TextUtils.isEmpty(getBody())) { - return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_media_message))); + if (extra != null && extra.isSticker()) { + return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_sticker))); + } else if (extra != null && extra.isRevealable() && MediaUtil.isImageType(contentType)) { + return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_photo))); + } else { + return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_media_message))); + } } else { return new SpannableString(getBody()); } diff --git a/src/org/thoughtcrime/securesms/groups/GroupManager.java b/src/org/thoughtcrime/securesms/groups/GroupManager.java index ceb97c96e3..7ec883a459 100644 --- a/src/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/src/org/thoughtcrime/securesms/groups/GroupManager.java @@ -115,7 +115,7 @@ public class GroupManager { avatarAttachment = new UriAttachment(avatarUri, MediaUtil.IMAGE_PNG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, avatar.length, null, false, false, null, null); } - OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, null, Collections.emptyList(), Collections.emptyList()); + OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, 0, null, Collections.emptyList(), Collections.emptyList()); long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); return new GroupActionResult(groupRecipient, threadId); diff --git a/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java b/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java index 054e546545..c7eb6cc595 100644 --- a/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java +++ b/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java @@ -212,7 +212,7 @@ public class GroupMessageProcessor { MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); Address addres = Address.fromExternal(context, GroupUtil.getEncodedId(group.getGroupId(), false)); Recipient recipient = Recipient.from(context, addres, false); - OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(recipient, storage, null, content.getTimestamp(), 0, null, Collections.emptyList(), Collections.emptyList()); + OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(recipient, storage, null, content.getTimestamp(), 0, 0, null, Collections.emptyList(), Collections.emptyList()); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); @@ -222,7 +222,7 @@ public class GroupMessageProcessor { } else { SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); String body = Base64.encodeBytes(storage.toByteArray()); - IncomingTextMessage incoming = new IncomingTextMessage(Address.fromExternal(context, content.getSender()), content.getSenderDevice(), content.getTimestamp(), body, Optional.of(group), 0, content.isNeedsReceipt()); + IncomingTextMessage incoming = new IncomingTextMessage(Address.fromExternal(context, content.getSender()), content.getSenderDevice(), content.getTimestamp(), body, Optional.of(group), 0, 0, content.isNeedsReceipt()); IncomingGroupMessage groupMessage = new IncomingGroupMessage(incoming, storage, body); Optional insertResult = smsDatabase.insertMessageInbox(groupMessage); diff --git a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index cd23905a30..664676f3e2 100644 --- a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -40,6 +40,7 @@ public final class JobManagerFactories { put(MultiDeviceGroupUpdateJob.KEY, new MultiDeviceGroupUpdateJob.Factory()); put(MultiDeviceProfileKeyUpdateJob.KEY, new MultiDeviceProfileKeyUpdateJob.Factory()); put(MultiDeviceReadUpdateJob.KEY, new MultiDeviceReadUpdateJob.Factory()); + put(MultiDeviceRevealUpdateJob.KEY, new MultiDeviceRevealUpdateJob.Factory()); put(MultiDeviceStickerPackOperationJob.KEY, new MultiDeviceStickerPackOperationJob.Factory()); put(MultiDeviceStickerPackSyncJob.KEY, new MultiDeviceStickerPackSyncJob.Factory()); put(MultiDeviceVerifiedUpdateJob.KEY, new MultiDeviceVerifiedUpdateJob.Factory()); diff --git a/src/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java b/src/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java index 5c340b4184..ca57a3cc5a 100644 --- a/src/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java +++ b/src/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java @@ -247,7 +247,7 @@ public class MmsDownloadJob extends BaseJob { group = Optional.of(Address.fromSerialized(DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(new LinkedList<>(members), true))); } - IncomingMediaMessage message = new IncomingMediaMessage(from, group, body, retrieved.getDate() * 1000L, attachments, subscriptionId, 0, false, false); + IncomingMediaMessage message = new IncomingMediaMessage(from, group, body, retrieved.getDate() * 1000L, attachments, subscriptionId, 0, false, 0, false); Optional insertResult = database.insertMessageInbox(message, contentLocation, threadId); if (insertResult.isPresent()) { diff --git a/src/org/thoughtcrime/securesms/jobs/MultiDeviceRevealUpdateJob.java b/src/org/thoughtcrime/securesms/jobs/MultiDeviceRevealUpdateJob.java new file mode 100644 index 0000000000..a14015e10e --- /dev/null +++ b/src/org/thoughtcrime/securesms/jobs/MultiDeviceRevealUpdateJob.java @@ -0,0 +1,124 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.MessageTimerReadMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.io.Serializable; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceRevealUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceRevealUpdateJob"; + + private static final String TAG = MultiDeviceRevealUpdateJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_ID = "message_id"; + + private SerializableSyncMessageId messageId; + + public MultiDeviceRevealUpdateJob(SyncMessageId messageId) { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + messageId); + } + + private MultiDeviceRevealUpdateJob(@NonNull Parameters parameters, @NonNull SyncMessageId syncMessageId) { + super(parameters); + this.messageId = new SerializableSyncMessageId(syncMessageId.getAddress().toPhoneString(), syncMessageId.getTimetamp()); + } + + @Override + public @NonNull Data serialize() { + String serialized; + + try { + serialized = JsonUtils.toJson(messageId); + } catch (IOException e) { + throw new AssertionError(e); + } + + return new Data.Builder().putString(KEY_MESSAGE_ID, serialized).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + MessageTimerReadMessage timerMessage = new MessageTimerReadMessage(messageId.sender, messageId.timestamp); + + messageSender.sendMessage(SignalServiceSyncMessage.forMessageTimerRead(timerMessage), UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof PushNetworkException; + } + + @Override + public void onCanceled() { + + } + + private static class SerializableSyncMessageId implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty + private final String sender; + + @JsonProperty + private final long timestamp; + + private SerializableSyncMessageId(@JsonProperty("sender") String sender, @JsonProperty("timestamp") long timestamp) { + this.sender = sender; + this.timestamp = timestamp; + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceRevealUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + SerializableSyncMessageId messageId; + + try { + messageId = JsonUtils.fromJson(data.getString(KEY_MESSAGE_ID), SerializableSyncMessageId.class); + } catch (IOException e) { + throw new AssertionError(e); + } + + SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(messageId.sender), messageId.timestamp); + + return new MultiDeviceRevealUpdateJob(parameters, syncMessageId); + } + } +} diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java index 6cd93b39fd..07c5dc3ad4 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.attachments.PointerAttachment; +import org.thoughtcrime.securesms.attachments.TombstoneAttachment; import org.thoughtcrime.securesms.attachments.UriAttachment; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactModelMapper; @@ -79,6 +80,8 @@ import org.thoughtcrime.securesms.mms.StickerSlide; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.revealable.RevealExpirationInfo; +import org.thoughtcrime.securesms.revealable.RevealableMessageManager; import org.thoughtcrime.securesms.service.WebRtcCallService; import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage; import org.thoughtcrime.securesms.sms.IncomingEndSessionMessage; @@ -109,6 +112,7 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage; import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.messages.multidevice.MessageTimerReadMessage; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; @@ -253,7 +257,8 @@ public class PushDecryptJob extends BaseJob { SignalServiceDataMessage message = content.getDataMessage().get(); boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent(); - if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); + if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), message.getGroupInfo(), content.getTimestamp(), smsMessageId); + else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId); else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId); else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId); @@ -278,6 +283,7 @@ public class PushDecryptJob extends BaseJob { if (syncMessage.getSent().isPresent()) handleSynchronizeSentMessage(content, syncMessage.getSent().get()); else if (syncMessage.getRequest().isPresent()) handleSynchronizeRequestMessage(syncMessage.getRequest().get()); else if (syncMessage.getRead().isPresent()) handleSynchronizeReadMessage(syncMessage.getRead().get(), content.getTimestamp()); + else if (syncMessage.getMessageTimerRead().isPresent()) handleSynchronizeMessageTimerReadMessage(syncMessage.getMessageTimerRead().get(), content.getTimestamp()); else if (syncMessage.getVerified().isPresent()) handleSynchronizeVerifiedMessage(syncMessage.getVerified().get()); else if (syncMessage.getStickerPackOperations().isPresent()) handleSynchronizeStickerPackOperation(syncMessage.getStickerPackOperations().get()); else Log.w(TAG, "Contains no known sync types..."); @@ -425,7 +431,7 @@ public class PushDecryptJob extends BaseJob { IncomingTextMessage incomingTextMessage = new IncomingTextMessage(Address.fromExternal(context, content.getSender()), content.getSenderDevice(), content.getTimestamp(), - "", Optional.absent(), 0, + "", Optional.absent(), 0, 0, content.isNeedsReceipt()); Long threadId; @@ -509,6 +515,7 @@ public class PushDecryptJob extends BaseJob { IncomingMediaMessage mediaMessage = new IncomingMediaMessage(Address.fromExternal(context, content.getSender()), message.getTimestamp(), -1, message.getExpiresInSeconds() * 1000L, true, + 0, content.isNeedsReceipt(), Optional.absent(), message.getGroupInfo(), @@ -669,6 +676,17 @@ public class PushDecryptJob extends BaseJob { MessageNotifier.updateNotification(context); } + private void handleSynchronizeMessageTimerReadMessage(@NonNull MessageTimerReadMessage timerMessage, long envelopeTimestamp) { + SyncMessageId messageId = new SyncMessageId(Address.fromExternal(context, timerMessage.getSender()), timerMessage.getTimestamp()); + + DatabaseFactory.getMmsDatabase(context).markRevealStarted(messageId, envelopeTimestamp); + ApplicationContext.getInstance(context).getRevealableMessageManager().scheduleIfNecessary(); + + MessageNotifier.setLastDesktopActivityTimestamp(envelopeTimestamp); + MessageNotifier.cancelDelayedNotifications(); + MessageNotifier.updateNotification(context); + } + private void handleMediaMessage(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message, @NonNull Optional smsMessageId) @@ -689,6 +707,7 @@ public class PushDecryptJob extends BaseJob { IncomingMediaMessage mediaMessage = new IncomingMediaMessage(Address.fromExternal(context, content.getSender()), message.getTimestamp(), -1, message.getExpiresInSeconds() * 1000L, false, + message.getMessageTimerInSeconds() * 1000, content.isNeedsReceipt(), message.getBody(), message.getGroupInfo(), @@ -698,7 +717,6 @@ public class PushDecryptJob extends BaseJob { linkPreviews, sticker); - insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); if (insertResult.isPresent()) { @@ -728,6 +746,10 @@ public class PushDecryptJob extends BaseJob { if (insertResult.isPresent()) { MessageNotifier.updateNotification(context, insertResult.get().getThreadId()); + + if (message.getMessageTimerInSeconds() > 0) { + ApplicationContext.getInstance(context).getRevealableMessageManager().scheduleIfNecessary(); + } } } @@ -758,7 +780,8 @@ public class PushDecryptJob extends BaseJob { Optional sticker = getStickerAttachment(message.getMessage().getSticker()); Optional> sharedContacts = getContacts(message.getMessage().getSharedContacts()); Optional> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or("")); - List syncAttachments = PointerAttachment.forPointers(message.getMessage().getAttachments()); + long messageTimer = message.getMessage().getMessageTimerInSeconds() * 1000; + List syncAttachments = messageTimer == 0 ? PointerAttachment.forPointers(message.getMessage().getAttachments()) : Collections.emptyList(); if (sticker.isPresent()) { syncAttachments.add(sticker.get()); @@ -768,6 +791,7 @@ public class PushDecryptJob extends BaseJob { syncAttachments, message.getTimestamp(), -1, message.getMessage().getExpiresInSeconds() * 1000, + messageTimer, ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(), sharedContacts.or(Collections.emptyList()), previews.or(Collections.emptyList()), @@ -897,6 +921,7 @@ public class PushDecryptJob extends BaseJob { message.getTimestamp(), body, message.getGroupInfo(), message.getExpiresInSeconds() * 1000L, + message.getMessageTimerInSeconds() * 1000L, content.isNeedsReceipt()); textMessage = new IncomingEncryptedMessage(textMessage, body); @@ -931,7 +956,7 @@ public class PushDecryptJob extends BaseJob { long messageId; if (isGroup) { - OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, new SlideDeck(), body, message.getTimestamp(), -1, expiresInMillis, ThreadDatabase.DistributionTypes.DEFAULT, null, Collections.emptyList(), Collections.emptyList()); + OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, new SlideDeck(), body, message.getTimestamp(), -1, expiresInMillis, 0, ThreadDatabase.DistributionTypes.DEFAULT, null, Collections.emptyList(), Collections.emptyList()); outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage); messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null); @@ -1035,6 +1060,26 @@ public class PushDecryptJob extends BaseJob { } } + private void handleInvalidMessage(@NonNull String sender, + int senderDevice, + @NonNull Optional group, + long timestamp, + @NonNull Optional smsMessageId) + { + SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + + if (!smsMessageId.isPresent()) { + Optional insertResult = insertPlaceholder(sender, senderDevice, timestamp, group); + + if (insertResult.isPresent()) { + smsDatabase.markAsInvalidMessage(insertResult.get().getMessageId()); + MessageNotifier.updateNotification(context, insertResult.get().getThreadId()); + } + } else { + smsDatabase.markAsNoSession(smsMessageId.get()); + } + } + private void handleLegacyMessage(@NonNull String sender, int senderDevice, long timestamp, @NonNull Optional smsMessageId) { @@ -1149,6 +1194,17 @@ public class PushDecryptJob extends BaseJob { } } + private boolean isInvalidMessage(@NonNull SignalServiceDataMessage message) { + if (message.getMessageTimerInSeconds() > 0) { + return !message.getAttachments().isPresent() || + message.getAttachments().get().size() != 1 || + !MediaUtil.isImageType(message.getAttachments().get().get(0).getContentType().toLowerCase()); + + } + + return false; + } + private Optional getValidatedQuote(Optional quote) { if (!quote.isPresent()) return Optional.absent(); @@ -1172,12 +1228,18 @@ public class PushDecryptJob extends BaseJob { if (message.isMms()) { 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()); + + if (mmsMessage.getRevealDuration() == 0) { + attachments = mmsMessage.getSlideDeck().asAttachments(); + + if (attachments.isEmpty()) { + attachments.addAll(Stream.of(mmsMessage.getLinkPreviews()) + .filter(lp -> lp.getThumbnail().isPresent()) + .map(lp -> lp.getThumbnail().get()) + .toList()); + } + } else if (quote.get().getAttachments().size() > 0) { + attachments.add(new TombstoneAttachment(quote.get().getAttachments().get(0).getContentType(), true)); } } @@ -1185,6 +1247,7 @@ public class PushDecryptJob extends BaseJob { } Log.w(TAG, "Didn't find matching message record..."); + return Optional.of(new QuoteModel(quote.get().getId(), author, quote.get().getText(), @@ -1272,7 +1335,7 @@ public class PushDecryptJob extends BaseJob { SmsDatabase database = DatabaseFactory.getSmsDatabase(context); IncomingTextMessage textMessage = new IncomingTextMessage(Address.fromExternal(context, sender), senderDevice, timestamp, "", - group, 0, false); + group, 0, 0, false); textMessage = new IncomingEncryptedMessage(textMessage, ""); return database.insertMessageInbox(textMessage); diff --git a/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index 736475af1a..2a75ad07db 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -194,6 +194,13 @@ public class PushGroupSendJob extends PushSendJob { .getExpiringMessageManager() .scheduleDeletion(messageId, true, message.getExpiresIn()); } + + if (message.getRevealDuration() > 0) { + database.markRevealStarted(messageId); + ApplicationContext.getInstance(context) + .getRevealableMessageManager() + .scheduleIfNecessary(); + } } else if (!networkFailures.isEmpty()) { throw new RetryLaterException(); } else if (!identityMismatches.isEmpty()) { @@ -262,6 +269,7 @@ public class PushGroupSendJob extends PushSendJob { .withAttachments(attachmentPointers) .withBody(message.getBody()) .withExpiration((int)(message.getExpiresIn() / 1000)) + .withMessageTimer((int)(message.getRevealDuration() / 1000)) .asExpirationUpdate(message.isExpirationUpdate()) .withProfileKey(profileKey.orNull()) .withQuote(quote.orNull()) diff --git a/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java index 0cf74c555d..40e026a122 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -159,6 +159,13 @@ public class PushMediaSendJob extends PushSendJob { expirationManager.scheduleDeletion(messageId, true, message.getExpiresIn()); } + if (message.getRevealDuration() > 0) { + database.markRevealStarted(messageId); + ApplicationContext.getInstance(context) + .getRevealableMessageManager() + .scheduleIfNecessary(); + } + log(TAG, "Sent message: " + messageId); } catch (InsecureFallbackApprovalException ifae) { @@ -210,6 +217,7 @@ public class PushMediaSendJob extends PushSendJob { .withAttachments(serviceAttachments) .withTimestamp(message.getSentTimeMillis()) .withExpiration((int)(message.getExpiresIn() / 1000)) + .withMessageTimer((int) message.getRevealDuration() / 1000) .withProfileKey(profileKey.orNull()) .withQuote(quote.orNull()) .withSticker(sticker.orNull()) diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 21341be3a9..5fbe15f241 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -47,7 +47,7 @@ import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.imageeditor.model.EditorModel; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; -import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.TimerState; +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.RevealState; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.BlobProvider; @@ -122,6 +122,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple private ViewGroup composeContainer; private ViewGroup countButton; private TextView countButtonText; + private ImageView revealButton; private EmojiEditText captionText; private EmojiToggle emojiToggle; private Stub emojiDrawer; @@ -191,6 +192,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple composeContainer = findViewById(R.id.mediasend_compose_container); countButton = findViewById(R.id.mediasend_count_button); countButtonText = findViewById(R.id.mediasend_count_button_text); + revealButton = findViewById(R.id.mediasend_reveal_toggle); captionText = findViewById(R.id.mediasend_caption); emojiToggle = findViewById(R.id.mediasend_emoji_toggle); charactersLeft = findViewById(R.id.mediasend_characters_left); @@ -289,6 +291,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple .or(recipient.getAddress().serialize())); composeText.setHint(getString(R.string.MediaSendActivity_message_to_s, displayName), null); } + composeText.setOnEditorActionListener((v, actionId, event) -> { boolean isSend = actionId == EditorInfo.IME_ACTION_SEND; if (isSend) sendButton.performClick(); @@ -302,6 +305,8 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple } initViewModel(); + + revealButton.setOnClickListener(v -> viewModel.onRevealButtonToggled()); } @Override @@ -512,14 +517,14 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple if (state == null) return; hud.setVisibility(state.isHudVisible() ? View.VISIBLE : View.GONE); - composeContainer.setVisibility(state.isComposeVisible() ? View.VISIBLE : (state.getTimerState() == TimerState.GONE ? View.GONE : View.INVISIBLE)); + composeContainer.setVisibility(state.isComposeVisible() ? View.VISIBLE : (state.getRevealState() == RevealState.GONE ? View.GONE : View.INVISIBLE)); captionText.setVisibility(state.isCaptionVisible() ? View.VISIBLE : View.GONE); int captionBackground; if (state.getRailState() == MediaSendViewModel.RailState.VIEWABLE) { captionBackground = R.color.core_grey_90; - } else if (state.getTimerState() == TimerState.ENABLED) { + } else if (state.getRevealState() == RevealState.ENABLED) { captionBackground = 0; } else { captionBackground = R.color.transparent_black_70; @@ -543,6 +548,20 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple break; } + switch (state.getRevealState()) { + case ENABLED: + revealButton.setVisibility(View.VISIBLE); + revealButton.setImageResource(R.drawable.ic_view_once_32); + break; + case DISABLED: + revealButton.setVisibility(View.VISIBLE); + revealButton.setImageResource(R.drawable.ic_view_infinite_32); + break; + case GONE: + revealButton.setVisibility(View.GONE); + break; + } + switch (state.getRailState()) { case INTERACTIVE: mediaRail.setVisibility(View.VISIBLE); diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java index 55d301f353..747cd04b7e 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -17,8 +17,10 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.revealable.RevealableUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; @@ -64,27 +66,27 @@ class MediaSendViewModel extends ViewModel { private boolean captionVisible; private ButtonState buttonState; private RailState railState; - private TimerState timerState; + private RevealState revealState; private MediaSendViewModel(@NonNull Application application, @NonNull MediaRepository repository) { - this.application = application; - this.repository = repository; - this.selectedMedia = new MutableLiveData<>(); - this.bucketMedia = new MutableLiveData<>(); - this.mostRecentMedia = new MutableLiveData<>(); - this.position = new MutableLiveData<>(); - this.bucketId = new MutableLiveData<>(); - this.folders = new MutableLiveData<>(); - this.hudState = new MutableLiveData<>(); - this.error = new SingleLiveEvent<>(); - this.savedDrawState = new HashMap<>(); - this.lastCameraCapture = Optional.absent(); - this.body = ""; - this.buttonState = ButtonState.GONE; - this.railState = RailState.GONE; - this.timerState = TimerState.GONE; - this.page = Page.UNKNOWN; + this.application = application; + this.repository = repository; + this.selectedMedia = new MutableLiveData<>(); + this.bucketMedia = new MutableLiveData<>(); + this.mostRecentMedia = new MutableLiveData<>(); + this.position = new MutableLiveData<>(); + this.bucketId = new MutableLiveData<>(); + this.folders = new MutableLiveData<>(); + this.hudState = new MutableLiveData<>(); + this.error = new SingleLiveEvent<>(); + this.savedDrawState = new HashMap<>(); + this.lastCameraCapture = Optional.absent(); + this.body = ""; + this.buttonState = ButtonState.GONE; + this.railState = RailState.GONE; + this.revealState = RevealState.GONE; + this.page = Page.UNKNOWN; position.setValue(-1); } @@ -171,7 +173,7 @@ class MediaSendViewModel extends ViewModel { captionVisible = false; buttonState = ButtonState.COUNT; railState = RailState.VIEWABLE; - timerState = TimerState.GONE; + revealState = RevealState.GONE; hudState.setValue(buildHudState()); } @@ -179,20 +181,28 @@ class MediaSendViewModel extends ViewModel { void onImageEditorStarted() { page = Page.EDITOR; hudVisible = true; - composeVisible = timerState != TimerState.ENABLED; + composeVisible = revealState != RevealState.ENABLED; captionVisible = getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent()); buttonState = ButtonState.SEND; - railState = !isSms ? RailState.INTERACTIVE : RailState.GONE; + + if (revealState == RevealState.GONE && revealSupported()) { + revealState = TextSecurePreferences.isRevealableMessageEnabled(application) ? RevealState.ENABLED : RevealState.DISABLED; + } else if (!revealSupported()) { + revealState = RevealState.GONE; + } + + railState = !isSms && revealState != RevealState.ENABLED ? RailState.INTERACTIVE : RailState.GONE; hudState.setValue(buildHudState()); } void onCameraStarted() { + // TODO: Don't need this? Page previous = page; page = Page.CAMERA; hudVisible = false; - timerState = TimerState.GONE; + revealState = RevealState.GONE; buttonState = ButtonState.COUNT; List selected = getSelectedMediaOrDefault(); @@ -212,7 +222,7 @@ class MediaSendViewModel extends ViewModel { composeVisible = false; captionVisible = false; buttonState = ButtonState.COUNT; - timerState = TimerState.GONE; + revealState = RevealState.GONE; railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE; lastCameraCapture = Optional.absent(); @@ -226,7 +236,7 @@ class MediaSendViewModel extends ViewModel { composeVisible = false; captionVisible = false; buttonState = ButtonState.COUNT; - timerState = TimerState.GONE; + revealState = RevealState.GONE; railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE; lastCameraCapture = Optional.absent(); @@ -234,10 +244,20 @@ class MediaSendViewModel extends ViewModel { hudState.setValue(buildHudState()); } - void onTimerButtonToggled() { + void onRevealButtonToggled() { hudVisible = true; - timerState = (timerState == TimerState.ENABLED) ? TimerState.DISABLED : TimerState.ENABLED; - composeVisible = (timerState != TimerState.ENABLED); + revealState = revealState == RevealState.ENABLED ? RevealState.DISABLED : RevealState.ENABLED; + composeVisible = revealState != RevealState.ENABLED; + railState = revealState == RevealState.ENABLED || isSms ? RailState.GONE : RailState.INTERACTIVE; + captionVisible = false; + + List uncaptioned = Stream.of(getSelectedMediaOrDefault()) + .map(m -> new Media(m.getUri(), m.getMimeType(), m.getDate(), m.getWidth(), m.getHeight(), m.getSize(), m.getBucketId(), Optional.absent())) + .toList(); + + selectedMedia.setValue(uncaptioned); + + TextSecurePreferences.setIsRevealableMessageEnabled(application, revealState == RevealState.ENABLED); hudState.setValue(buildHudState()); } @@ -245,14 +265,14 @@ class MediaSendViewModel extends ViewModel { void onKeyboardHidden(boolean isSms) { if (page != Page.EDITOR) return; - composeVisible = (timerState != TimerState.ENABLED); + composeVisible = (revealState != RevealState.ENABLED); buttonState = ButtonState.SEND; if (isSms) { railState = RailState.GONE; captionVisible = false; } else { - railState = RailState.INTERACTIVE; + railState = revealState != RevealState.ENABLED ? RailState.INTERACTIVE : RailState.GONE; if (getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent())) { captionVisible = true; @@ -267,18 +287,18 @@ class MediaSendViewModel extends ViewModel { if (isSms) { railState = RailState.GONE; - composeVisible = (timerState == TimerState.GONE); + composeVisible = (revealState == RevealState.GONE); captionVisible = false; buttonState = ButtonState.SEND; } else { if (isCaptionFocused) { - railState = RailState.INTERACTIVE; + railState = revealState != RevealState.ENABLED ? RailState.INTERACTIVE : RailState.GONE; composeVisible = false; captionVisible = true; buttonState = ButtonState.GONE; } else if (isComposeFocused) { - railState = RailState.INTERACTIVE; - composeVisible = (timerState != TimerState.ENABLED); + railState = revealState != RevealState.ENABLED ? RailState.INTERACTIVE : RailState.GONE; + composeVisible = (revealState != RevealState.ENABLED); captionVisible = false; buttonState = ButtonState.SEND; } @@ -327,6 +347,10 @@ class MediaSendViewModel extends ViewModel { this.position.setValue(Math.min(position, getSelectedMediaOrDefault().size() - 1)); } + if (getSelectedMediaOrDefault().size() == 1) { + revealState = revealSupported() ? RevealState.DISABLED : RevealState.GONE; + } + hudState.setValue(buildHudState()); } @@ -350,16 +374,6 @@ class MediaSendViewModel extends ViewModel { bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); } - void onImageCaptureUndo(@NonNull Context context) { - List selected = getSelectedMediaOrDefault(); - - if (lastCameraCapture.isPresent() && selected.contains(lastCameraCapture.get()) && selected.size() == 1) { - selected.remove(lastCameraCapture.get()); - selectedMedia.setValue(selected); - BlobProvider.getInstance().delete(context, lastCameraCapture.get().getUri()); - } - } - void onCaptionChanged(@NonNull String newCaption) { if (position.getValue() >= 0 && !Util.isEmpty(selectedMedia.getValue())) { selectedMedia.getValue().get(position.getValue()).setCaption(TextUtils.isEmpty(newCaption) ? null : newCaption); @@ -426,6 +440,8 @@ class MediaSendViewModel extends ViewModel { } long getRevealDuration() { + // TODO[reveal] +// return revealState == RevealState.ENABLED ? RevealableUtil.DURATION : 0; return 0; } @@ -447,12 +463,14 @@ class MediaSendViewModel extends ViewModel { } private HudState buildHudState() { - List selectedMedia = getSelectedMediaOrDefault(); - int selectionCount = selectedMedia.size(); - ButtonState updatedButtonState = buttonState == ButtonState.COUNT && selectionCount == 0 ? ButtonState.GONE : buttonState; - boolean updatdCaptionVisible = captionVisible && (selectedMedia.size() > 1 || (selectedMedia.size() > 0 && selectedMedia.get(0).getCaption().isPresent())); + // TODO[reveal] + RevealState updatedRevealState = RevealState.GONE; + List selectedMedia = getSelectedMediaOrDefault(); + int selectionCount = selectedMedia.size(); + ButtonState updatedButtonState = buttonState == ButtonState.COUNT && selectionCount == 0 ? ButtonState.GONE : buttonState; + boolean updatedCaptionVisible = captionVisible && (selectedMedia.size() > 1 || (selectedMedia.size() > 0 && selectedMedia.get(0).getCaption().isPresent())); - return new HudState(hudVisible, composeVisible, updatdCaptionVisible, selectionCount, updatedButtonState, railState, timerState); + return new HudState(hudVisible, composeVisible, updatedCaptionVisible, selectionCount, updatedButtonState, railState, updatedRevealState); } private void clearPersistedMedia() { @@ -462,6 +480,14 @@ class MediaSendViewModel extends ViewModel { .forEach(uri -> BlobProvider.getInstance().delete(application.getApplicationContext(), uri)); } + private boolean revealSupported() { + return !isSms && mediaSupportsRevealableMessage(getSelectedMediaOrDefault()); + } + + private boolean mediaSupportsRevealableMessage(@NonNull List media) { + return media.size() == 1 && MediaUtil.isImageType(media.get(0).getMimeType()); + } + @Override protected void onCleared() { if (!sentMedia) { @@ -485,7 +511,7 @@ class MediaSendViewModel extends ViewModel { INTERACTIVE, VIEWABLE, GONE } - enum TimerState { + enum RevealState { ENABLED, DISABLED, GONE } @@ -497,7 +523,7 @@ class MediaSendViewModel extends ViewModel { private final int selectionCount; private final ButtonState buttonState; private final RailState railState; - private final TimerState timerState; + private final RevealState revealState; HudState(boolean hudVisible, boolean composeVisible, @@ -505,7 +531,7 @@ class MediaSendViewModel extends ViewModel { int selectionCount, @NonNull ButtonState buttonState, @NonNull RailState railState, - @NonNull TimerState timerState) + @NonNull RevealState revealState) { this.hudVisible = hudVisible; this.composeVisible = composeVisible; @@ -513,7 +539,7 @@ class MediaSendViewModel extends ViewModel { this.selectionCount = selectionCount; this.buttonState = buttonState; this.railState = railState; - this.timerState = timerState; + this.revealState = revealState; } public boolean isHudVisible() { @@ -540,8 +566,9 @@ class MediaSendViewModel extends ViewModel { return hudVisible ? railState : RailState.GONE; } - public @NonNull TimerState getTimerState() { - return hudVisible ? timerState : TimerState.GONE; + public @NonNull + RevealState getRevealState() { + return hudVisible ? revealState : RevealState.GONE; } } diff --git a/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java b/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java index 8d0c19962c..5c85f0c072 100644 --- a/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java +++ b/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java @@ -24,6 +24,7 @@ public class IncomingMediaMessage { private final int subscriptionId; private final long expiresIn; private final boolean expirationUpdate; + private final long revealDuration; private final QuoteModel quote; private final boolean unidentified; @@ -39,6 +40,7 @@ public class IncomingMediaMessage { int subscriptionId, long expiresIn, boolean expirationUpdate, + long revealDuration, boolean unidentified) { this.from = from; @@ -49,6 +51,7 @@ public class IncomingMediaMessage { this.subscriptionId = subscriptionId; this.expiresIn = expiresIn; this.expirationUpdate = expirationUpdate; + this.revealDuration = revealDuration; this.quote = null; this.unidentified = unidentified; @@ -60,6 +63,7 @@ public class IncomingMediaMessage { int subscriptionId, long expiresIn, boolean expirationUpdate, + long revealDuration, boolean unidentified, Optional body, Optional group, @@ -76,6 +80,7 @@ public class IncomingMediaMessage { this.subscriptionId = subscriptionId; this.expiresIn = expiresIn; this.expirationUpdate = expirationUpdate; + this.revealDuration = revealDuration; this.quote = quote.orNull(); this.unidentified = unidentified; @@ -127,6 +132,10 @@ public class IncomingMediaMessage { return expiresIn; } + public long getRevealDuration() { + return revealDuration; + } + public boolean isGroupMessage() { return groupId != null; } diff --git a/src/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java b/src/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java index 3951cb0aab..bd521a7310 100644 --- a/src/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java +++ b/src/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java @@ -11,7 +11,7 @@ public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn) { super(recipient, "", new LinkedList(), sentTimeMillis, - ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, null, Collections.emptyList(), + ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, 0, null, Collections.emptyList(), Collections.emptyList()); } diff --git a/src/org/thoughtcrime/securesms/mms/OutgoingGroupMediaMessage.java b/src/org/thoughtcrime/securesms/mms/OutgoingGroupMediaMessage.java index 27ad272116..36d567acfa 100644 --- a/src/org/thoughtcrime/securesms/mms/OutgoingGroupMediaMessage.java +++ b/src/org/thoughtcrime/securesms/mms/OutgoingGroupMediaMessage.java @@ -24,13 +24,14 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage { @NonNull List avatar, long sentTimeMillis, long expiresIn, + long revealDuration, @Nullable QuoteModel quote, @NonNull List contacts, @NonNull List previews) throws IOException { super(recipient, encodedGroupContext, avatar, sentTimeMillis, - ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, quote, contacts, previews); + ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, revealDuration, quote, contacts, previews); this.group = GroupContext.parseFrom(Base64.decode(encodedGroupContext)); } @@ -40,6 +41,7 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage { @Nullable final Attachment avatar, long sentTimeMillis, long expireIn, + long revealDuration, @Nullable QuoteModel quote, @NonNull List contacts, @NonNull List previews) @@ -47,7 +49,7 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage { super(recipient, Base64.encodeBytes(group.toByteArray()), new LinkedList() {{if (avatar != null) add(avatar);}}, System.currentTimeMillis(), - ThreadDatabase.DistributionTypes.CONVERSATION, expireIn, quote, contacts, previews); + ThreadDatabase.DistributionTypes.CONVERSATION, expireIn, revealDuration, quote, contacts, previews); this.group = group; } diff --git a/src/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java b/src/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java index 6a73726448..cc36527bc4 100644 --- a/src/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java +++ b/src/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java @@ -23,6 +23,7 @@ public class OutgoingMediaMessage { private final int distributionType; private final int subscriptionId; private final long expiresIn; + private final long revealDuration; private final QuoteModel outgoingQuote; private final List networkFailures = new LinkedList<>(); @@ -32,7 +33,7 @@ public class OutgoingMediaMessage { public OutgoingMediaMessage(Recipient recipient, String message, List attachments, long sentTimeMillis, - int subscriptionId, long expiresIn, + int subscriptionId, long expiresIn, long revealDuration, int distributionType, @Nullable QuoteModel outgoingQuote, @NonNull List contacts, @@ -47,6 +48,7 @@ public class OutgoingMediaMessage { this.attachments = attachments; this.subscriptionId = subscriptionId; this.expiresIn = expiresIn; + this.revealDuration = revealDuration; this.outgoingQuote = outgoingQuote; this.contacts.addAll(contacts); @@ -57,7 +59,8 @@ public class OutgoingMediaMessage { public OutgoingMediaMessage(Recipient recipient, SlideDeck slideDeck, String message, long sentTimeMillis, int subscriptionId, long expiresIn, - int distributionType, @Nullable QuoteModel outgoingQuote, + long revealDuration, int distributionType, + @Nullable QuoteModel outgoingQuote, @NonNull List contacts, @NonNull List linkPreviews) { @@ -65,7 +68,7 @@ public class OutgoingMediaMessage { buildMessage(slideDeck, message), slideDeck.asAttachments(), sentTimeMillis, subscriptionId, - expiresIn, distributionType, outgoingQuote, + expiresIn, revealDuration, distributionType, outgoingQuote, contacts, linkPreviews, new LinkedList<>(), new LinkedList<>()); } @@ -77,6 +80,7 @@ public class OutgoingMediaMessage { this.sentTimeMillis = that.sentTimeMillis; this.subscriptionId = that.subscriptionId; this.expiresIn = that.expiresIn; + this.revealDuration = that.revealDuration; this.outgoingQuote = that.outgoingQuote; this.identityKeyMismatches.addAll(that.identityKeyMismatches); @@ -125,6 +129,10 @@ public class OutgoingMediaMessage { return expiresIn; } + public long getRevealDuration() { + return revealDuration; + } + public @Nullable QuoteModel getOutgoingQuote() { return outgoingQuote; } diff --git a/src/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java b/src/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java index 8332c06575..839b26ce44 100644 --- a/src/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java +++ b/src/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java @@ -18,11 +18,12 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { long sentTimeMillis, int distributionType, long expiresIn, + long revealDuration, @Nullable QuoteModel quote, @NonNull List contacts, @NonNull List previews) { - super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList()); + super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, revealDuration, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList()); } public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { diff --git a/src/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/src/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index 31d704f9b2..09c9ed8c69 100644 --- a/src/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/src/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -76,7 +76,7 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { if (recipient.isGroupRecipient()) { Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); - OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, 0, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); replyThreadId = MessageSender.send(context, reply, threadId, false, null); } else { Log.w("AndroidAutoReplyReceiver", "Sending regular message "); diff --git a/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java index 9dd2aaff27..88f98c389d 100644 --- a/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java +++ b/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -465,6 +465,9 @@ public class MessageNotifier { } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_sticker)); slideDeck = ((MmsMessageRecord) record).getSlideDeck(); + } else if (record.isMms() && ((MmsMessageRecord) record).getRevealDuration() > 0) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_disappearing_photo)); + slideDeck = ((MmsMessageRecord) record).getSlideDeck(); } else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message)); slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); diff --git a/src/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/src/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 3cd7789c68..7276d03b02 100644 --- a/src/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/src/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -76,7 +76,7 @@ public class RemoteReplyReceiver extends BroadcastReceiver { switch (replyMethod) { case GroupMessage: { - OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, 0, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); threadId = MessageSender.send(context, reply, -1, false, null); break; } diff --git a/src/org/thoughtcrime/securesms/revealable/RevealExpirationInfo.java b/src/org/thoughtcrime/securesms/revealable/RevealExpirationInfo.java new file mode 100644 index 0000000000..eb5e07f097 --- /dev/null +++ b/src/org/thoughtcrime/securesms/revealable/RevealExpirationInfo.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.revealable; + +public class RevealExpirationInfo { + + private final long messageId; + private final long receiveTime; + private final long revealStartTime; + private final long revealDuration; + + public RevealExpirationInfo(long messageId, long receiveTime, long revealStartTime, long revealDuration) { + this.messageId = messageId; + this.receiveTime = receiveTime; + this.revealStartTime = revealStartTime; + this.revealDuration = revealDuration; + } + + public long getMessageId() { + return messageId; + } + + public long getReceiveTime() { + return receiveTime; + } + + public long getRevealStartTime() { + return revealStartTime; + } + + public long getRevealDuration() { + return revealDuration; + } +} diff --git a/src/org/thoughtcrime/securesms/revealable/RevealableMessageActivity.java b/src/org/thoughtcrime/securesms/revealable/RevealableMessageActivity.java new file mode 100644 index 0000000000..e05b5701a7 --- /dev/null +++ b/src/org/thoughtcrime/securesms/revealable/RevealableMessageActivity.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.revealable; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; + +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.Util; + +public class RevealableMessageActivity extends PassphraseRequiredActionBarActivity { + + private static final String TAG = Log.tag(RevealableMessageActivity.class); + + private static final String KEY_MESSAGE_ID = "message_id"; + + private ImageView image; + private View closeButton; + private RevealableMessageViewModel viewModel; + + public static Intent getIntent(@NonNull Context context, long messageId) { + Intent intent = new Intent(context, RevealableMessageActivity.class); + intent.putExtra(KEY_MESSAGE_ID, messageId); + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + setContentView(R.layout.revealable_message_activity); + + this.image = findViewById(R.id.reveal_image); + this.closeButton = findViewById(R.id.reveal_close_button); + + image.setOnClickListener(v -> finish()); + closeButton.setOnClickListener(v -> finish()); + + initViewModel(getIntent().getLongExtra(KEY_MESSAGE_ID, -1)); + } + + private void initViewModel(long messageId) { + RevealableMessageRepository repository = new RevealableMessageRepository(this); + viewModel = ViewModelProviders.of(this, new RevealableMessageViewModel.Factory(getApplication(), messageId, repository)) + .get(RevealableMessageViewModel.class); + + viewModel.getMessage().observe(this, (message) -> { + if (message == null) return; + + if (message.isPresent()) { + //noinspection ConstantConditions + GlideApp.with(this) + .load(new DecryptableUri(message.get().getSlideDeck().getThumbnailSlide().getUri())) + .into(image); + } else { + image.setImageDrawable(null); + finish(); + } + }); + } +} diff --git a/src/org/thoughtcrime/securesms/revealable/RevealableMessageManager.java b/src/org/thoughtcrime/securesms/revealable/RevealableMessageManager.java new file mode 100644 index 0000000000..9d7df2f8ba --- /dev/null +++ b/src/org/thoughtcrime/securesms/revealable/RevealableMessageManager.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.revealable; + +import android.app.Application; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.service.TimedEventManager; + +/** + * Manages clearing removable message content after they're opened. + */ +public class RevealableMessageManager extends TimedEventManager { + + private static final String TAG = Log.tag(RevealableMessageManager.class); + + private final MmsDatabase mmsDatabase; + private final AttachmentDatabase attachmentDatabase; + + public RevealableMessageManager(@NonNull Application application) { + super(application, "RevealableMessageManager"); + + this.mmsDatabase = DatabaseFactory.getMmsDatabase(application); + this.attachmentDatabase = DatabaseFactory.getAttachmentDatabase(application); + } + + @WorkerThread + @Override + protected @Nullable RevealExpirationInfo getNextClosestEvent() { + RevealExpirationInfo expirationInfo = mmsDatabase.getNearestExpiringRevealableMessage(); + + if (expirationInfo != null) { + Log.i(TAG, "Next closest expiration is in " + getDelayForEvent(expirationInfo) + " ms for messsage " + expirationInfo.getMessageId() + "."); + } else { + Log.i(TAG, "No messages to schedule."); + } + + return expirationInfo; + } + + @WorkerThread + @Override + protected void executeEvent(@NonNull RevealExpirationInfo event) { + Log.i(TAG, "Deleting attachments for message " + event.getMessageId()); + attachmentDatabase.deleteAttachmentFilesForMessage(event.getMessageId()); + } + + @WorkerThread + @Override + protected long getDelayForEvent(@NonNull RevealExpirationInfo event) { + if (event.getRevealStartTime() == 0) { + return event.getReceiveTime() + RevealableUtil.MAX_LIFESPAN; + } else { + long timeSinceStart = System.currentTimeMillis() - event.getRevealStartTime(); + long timeLeft = event.getRevealDuration() - timeSinceStart; + + return Math.max(0, timeLeft); + } + } + + @AnyThread + @Override + protected void scheduleAlarm(@NonNull Application application, long delay) { + setAlarm(application, delay, RevealAlarm.class); + } + + public static class RevealAlarm extends BroadcastReceiver { + + private static final String TAG = Log.tag(RevealAlarm.class); + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "onReceive()"); + ApplicationContext.getInstance(context).getRevealableMessageManager().scheduleIfNecessary(); + } + } +} diff --git a/src/org/thoughtcrime/securesms/revealable/RevealableMessageRepository.java b/src/org/thoughtcrime/securesms/revealable/RevealableMessageRepository.java new file mode 100644 index 0000000000..dbce55a718 --- /dev/null +++ b/src/org/thoughtcrime/securesms/revealable/RevealableMessageRepository.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.revealable; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.whispersystems.libsignal.util.guava.Optional; + +class RevealableMessageRepository { + + private static final String TAG = Log.tag(RevealableMessageRepository.class); + + private final MmsDatabase mmsDatabase; + + RevealableMessageRepository(@NonNull Context context) { + this.mmsDatabase = DatabaseFactory.getMmsDatabase(context); + } + + void getMessage(long messageId, @NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + try (MmsDatabase.Reader reader = mmsDatabase.readerFor(mmsDatabase.getMessage(messageId))) { + MmsMessageRecord record = (MmsMessageRecord) reader.getNext(); + callback.onComplete(Optional.fromNullable(record)); + } + }); + } + + interface Callback { + void onComplete(T result); + } +} diff --git a/src/org/thoughtcrime/securesms/revealable/RevealableMessageView.java b/src/org/thoughtcrime/securesms/revealable/RevealableMessageView.java new file mode 100644 index 0000000000..03c116dbd9 --- /dev/null +++ b/src/org/thoughtcrime/securesms/revealable/RevealableMessageView.java @@ -0,0 +1,174 @@ +package org.thoughtcrime.securesms.revealable; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.os.Handler; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.Util; + +public class RevealableMessageView extends LinearLayout { + + private static final String TAG = Log.tag(RevealableMessageView.class); + + private ImageView icon; + private ProgressWheel progress; + private TextView text; + private Handler handler; + private Runnable updateRunnable; + private Attachment attachment; + private int unopenedForegroundColor; + private int openedForegroundColor; + private int foregroundColor; + + public RevealableMessageView(Context context) { + super(context); + init(null); + } + + public RevealableMessageView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.revealable_message_view, this); + setOrientation(LinearLayout.HORIZONTAL); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.RevealableMessageView, 0, 0); + + unopenedForegroundColor = typedArray.getColor(R.styleable.RevealableMessageView_revealable_unopenedForegroundColor, Color.BLACK); + openedForegroundColor = typedArray.getColor(R.styleable.RevealableMessageView_revealable_openedForegroundColor, Color.BLACK); + + typedArray.recycle(); + } + + this.icon = findViewById(R.id.revealable_icon); + this.progress = findViewById(R.id.revealable_progress); + this.text = findViewById(R.id.revealable_text); + this.handler = new Handler(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + EventBus.getDefault().unregister(this); + } + + public boolean requiresTapToDownload(@NonNull MmsMessageRecord messageRecord) { + if (messageRecord.isOutgoing() || messageRecord.getSlideDeck().getThumbnailSlide() == null) { + return false; + } + + Attachment attachment = messageRecord.getSlideDeck().getThumbnailSlide().asAttachment(); + return attachment.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_FAILED || + attachment.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING; + } + + public void setMessage(@NonNull MmsMessageRecord message) { + this.attachment = message.getSlideDeck().getThumbnailSlide() != null ? message.getSlideDeck().getThumbnailSlide().asAttachment() : null; + + clearUpdateRunnable(); + presentMessage(message); + } + + public void presentMessage(@NonNull MmsMessageRecord message) { + presentText(message); + } + + private void presentText(@NonNull MmsMessageRecord messageRecord) { + if (downloadInProgress(messageRecord) && messageRecord.isOutgoing()) { + foregroundColor = unopenedForegroundColor; + text.setText(R.string.RevealableMessageView_view_photo); + icon.setImageResource(0); + progress.setVisibility(VISIBLE); + } else if (downloadInProgress(messageRecord)) { + foregroundColor = unopenedForegroundColor; + text.setText(""); + icon.setImageResource(0); + progress.setVisibility(VISIBLE); + } else if (requiresTapToDownload(messageRecord)) { + foregroundColor = unopenedForegroundColor; + text.setText(formatFileSize(messageRecord)); + icon.setImageResource(R.drawable.ic_arrow_down_circle_outline_24); + progress.setVisibility(GONE); + } else if (RevealableUtil.isViewable(messageRecord)) { + foregroundColor = unopenedForegroundColor; + text.setText(R.string.RevealableMessageView_view_photo); + icon.setImageResource(R.drawable.ic_play_solid_24); + progress.setVisibility(GONE); + } else if (messageRecord.isOutgoing()) { + foregroundColor = openedForegroundColor; + text.setText(R.string.RevealableMessageView_photo); + icon.setImageResource(R.drawable.ic_play_outline_24); + progress.setVisibility(GONE); + } else { + foregroundColor = openedForegroundColor; + text.setText(R.string.RevealableMessageView_viewed); + icon.setImageResource(R.drawable.ic_play_outline_24); + progress.setVisibility(GONE); + clearUpdateRunnable(); + } + + text.setTextColor(foregroundColor); + icon.setColorFilter(foregroundColor); + progress.setBarColor(foregroundColor); + progress.setRimColor(Color.TRANSPARENT); + } + + private boolean downloadInProgress(@NonNull MmsMessageRecord messageRecord) { + if (messageRecord.getSlideDeck().getThumbnailSlide() == null) return false; + + Attachment attachment = messageRecord.getSlideDeck().getThumbnailSlide().asAttachment(); + return attachment.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED; + } + + private void clearUpdateRunnable() { + if (updateRunnable != null) { + handler.removeCallbacks(updateRunnable); + updateRunnable = null; + } + } + + private @NonNull String formatFileSize(@NonNull MmsMessageRecord messageRecord) { + if (messageRecord.getSlideDeck().getThumbnailSlide() == null) return ""; + + long size = messageRecord.getSlideDeck().getThumbnailSlide().getFileSize(); + return Util.getPrettyFileSize(size); + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onEventAsync(final PartProgressEvent event) { + if (event.attachment.equals(attachment)) { + progress.setInstantProgress((float) event.progress / (float) event.total); + } + } +} diff --git a/src/org/thoughtcrime/securesms/revealable/RevealableMessageViewModel.java b/src/org/thoughtcrime/securesms/revealable/RevealableMessageViewModel.java new file mode 100644 index 0000000000..7993d9b725 --- /dev/null +++ b/src/org/thoughtcrime/securesms/revealable/RevealableMessageViewModel.java @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.revealable; + +import android.app.Application; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +class RevealableMessageViewModel extends ViewModel { + + private static final String TAG = Log.tag(RevealableMessageViewModel.class); + + private final Application application; + private final RevealableMessageRepository repository; + private final MutableLiveData> message; + private final ContentObserver observer; + + private RevealableMessageViewModel(@NonNull Application application, + long messageId, + @NonNull RevealableMessageRepository repository) + { + this.application = application; + this.repository = repository; + this.message = new MutableLiveData<>(); + this.observer = new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + repository.getMessage(messageId, optionalMessage -> onMessageRetrieved(optionalMessage)); + } + }; + + repository.getMessage(messageId, message -> { + if (message.isPresent()) { + Uri uri = DatabaseContentProviders.Conversation.getUriForThread(message.get().getThreadId()); + application.getContentResolver().registerContentObserver(uri, true, observer); + } + + onMessageRetrieved(message); + }); + } + + @NonNull LiveData> getMessage() { + return message; + } + + @Override + protected void onCleared() { + application.getContentResolver().unregisterContentObserver(observer); + } + + private void onMessageRetrieved(@NonNull Optional optionalMessage) { + Util.runOnMain(() -> { + MmsMessageRecord current = message.getValue() != null ? message.getValue().orNull() : null; + MmsMessageRecord proposed = optionalMessage.orNull(); + + if (current != null && proposed != null && current.getId() == proposed.getId()) { + Log.d(TAG, "Same ID -- skipping update"); + } else { + message.setValue(optionalMessage); + } + }); + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final Application application; + private final long messageId; + private final RevealableMessageRepository repository; + + Factory(@NonNull Application application, + long messageId, + @NonNull RevealableMessageRepository repository) + { + this.application = application; + this.messageId = messageId; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new RevealableMessageViewModel(application, messageId, repository)); + } + } +} diff --git a/src/org/thoughtcrime/securesms/revealable/RevealableUtil.java b/src/org/thoughtcrime/securesms/revealable/RevealableUtil.java new file mode 100644 index 0000000000..e1c3f06d43 --- /dev/null +++ b/src/org/thoughtcrime/securesms/revealable/RevealableUtil.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.revealable; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; + +import java.util.concurrent.TimeUnit; + +public class RevealableUtil { + + public static final long MAX_LIFESPAN = TimeUnit.DAYS.toMillis(30); + public static final long DURATION = TimeUnit.SECONDS.toMillis(5); + + public static boolean isViewable(@Nullable MmsMessageRecord message) { + if (message.getRevealDuration() == 0) { + return true; + } else if (message.getSlideDeck().getThumbnailSlide() == null) { + return false; + } else if (message.getSlideDeck().getThumbnailSlide().getUri() == null) { + return false; + } else if (message.isOutgoing() && message.getSlideDeck().getThumbnailSlide().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) { + return true; + } else if (message.getSlideDeck().getThumbnailSlide().getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + return false; + } else if (isRevealExpired(message)) { + return false; + } else { + return true; + } + } + + public static boolean isRevealExpired(@Nullable MmsMessageRecord message) { + if (message == null) { + return false; + } else if (message.getRevealDuration() == 0) { + return false; + } else if (message.getDateReceived() + MAX_LIFESPAN < System.currentTimeMillis()) { + return true; + } else if (message.getRevealStartTime() == 0) { + return false; + } else if (message.getRevealStartTime() + message.getRevealDuration() < System.currentTimeMillis()) { + return true; + } else { + return false; + } + } + + public static boolean hasStarted(@Nullable MmsMessageRecord record) { + return record != null && record.getRevealStartTime() != 0; + } + + public static boolean hasMedia(@Nullable MmsMessageRecord record) { + return record != null && + record.getSlideDeck().getThumbnailSlide() != null && + record.getSlideDeck().getThumbnailSlide().getUri() != null && + record.getSlideDeck().getThumbnailSlide().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE; + } +} diff --git a/src/org/thoughtcrime/securesms/service/TimedEventManager.java b/src/org/thoughtcrime/securesms/service/TimedEventManager.java new file mode 100644 index 0000000000..5cbbdf0a79 --- /dev/null +++ b/src/org/thoughtcrime/securesms/service/TimedEventManager.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.service; + +import android.app.AlarmManager; +import android.app.Application; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.HandlerThread; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.util.ServiceUtil; + +/** + * Class to help manage scheduling events to happen in the future, whether the app is open or not. + */ +public abstract class TimedEventManager { + + private final Application application; + private final Handler handler; + + public TimedEventManager(@NonNull Application application, @NonNull String threadName) { + HandlerThread handlerThread = new HandlerThread(threadName); + handlerThread.start(); + + this.application = application; + this.handler = new Handler(handlerThread.getLooper()); + + scheduleIfNecessary(); + } + + /** + * Should be called whenever the underlying data of events has changed. Will appropriately + * schedule new event executions. + */ + public void scheduleIfNecessary() { + handler.removeCallbacksAndMessages(null); + + handler.post(() -> { + E event = getNextClosestEvent(); + + if (event != null) { + long delay = getDelayForEvent(event); + + handler.postDelayed(() -> { + executeEvent(event); + scheduleIfNecessary(); + }, delay); + + scheduleAlarm(application, delay); + } + }); + } + + /** + * @return The next event that should be executed, or {@code null} if there are no events to execute. + */ + @WorkerThread + protected @Nullable abstract E getNextClosestEvent(); + + /** + * Execute the provided event. + */ + @WorkerThread + protected abstract void executeEvent(@NonNull E event); + + /** + * @return How long before the provided event should be executed. + */ + @WorkerThread + protected abstract long getDelayForEvent(@NonNull E event); + + /** + * Schedules an alarm to call {@link #scheduleIfNecessary()} after the specified delay. You can + * use {@link #setAlarm(Context, long, Class)} as a helper method. + */ + @AnyThread + protected abstract void scheduleAlarm(@NonNull Application application, long delay); + + /** + * Helper method to set an alarm. + */ + protected static void setAlarm(@NonNull Context context, long delay, @NonNull Class alarmClass) { + Intent intent = new Intent(context, alarmClass); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0); + AlarmManager alarmManager = ServiceUtil.getAlarmManager(context); + + alarmManager.cancel(pendingIntent); + alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + delay, pendingIntent); + } +} diff --git a/src/org/thoughtcrime/securesms/sms/IncomingJoinedMessage.java b/src/org/thoughtcrime/securesms/sms/IncomingJoinedMessage.java index 8ce4bec8b4..658caddb1a 100644 --- a/src/org/thoughtcrime/securesms/sms/IncomingJoinedMessage.java +++ b/src/org/thoughtcrime/securesms/sms/IncomingJoinedMessage.java @@ -7,7 +7,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroup; public class IncomingJoinedMessage extends IncomingTextMessage { public IncomingJoinedMessage(Address sender) { - super(sender, 1, System.currentTimeMillis(), null, Optional.absent(), 0, false); + super(sender, 1, System.currentTimeMillis(), null, Optional.absent(), 0, 0, false); } @Override diff --git a/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java b/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java index 92c53ea6d2..4ebddcd739 100644 --- a/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java +++ b/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java @@ -42,6 +42,7 @@ public class IncomingTextMessage implements Parcelable { private final boolean push; private final int subscriptionId; private final long expiresInMillis; + private final long revealDuration; private final boolean unidentified; public IncomingTextMessage(@NonNull Context context, @NonNull SmsMessage message, int subscriptionId) { @@ -55,6 +56,7 @@ public class IncomingTextMessage implements Parcelable { this.sentTimestampMillis = message.getTimestampMillis(); this.subscriptionId = subscriptionId; this.expiresInMillis = 0; + this.revealDuration = 0; this.groupId = null; this.push = false; this.unidentified = false; @@ -62,7 +64,7 @@ public class IncomingTextMessage implements Parcelable { public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, String encodedBody, Optional group, - long expiresInMillis, boolean unidentified) + long expiresInMillis, long revealDuration, boolean unidentified) { this.message = encodedBody; this.sender = sender; @@ -75,6 +77,7 @@ public class IncomingTextMessage implements Parcelable { this.push = true; this.subscriptionId = -1; this.expiresInMillis = expiresInMillis; + this.revealDuration = revealDuration; this.unidentified = unidentified; if (group.isPresent()) { @@ -97,6 +100,7 @@ public class IncomingTextMessage implements Parcelable { this.push = (in.readInt() == 1); this.subscriptionId = in.readInt(); this.expiresInMillis = in.readLong(); + this.revealDuration = in.readLong(); this.unidentified = in.readInt() == 1; } @@ -113,6 +117,7 @@ public class IncomingTextMessage implements Parcelable { this.push = base.isPush(); this.subscriptionId = base.getSubscriptionId(); this.expiresInMillis = base.getExpiresIn(); + this.revealDuration = base.getRevealDuration(); this.unidentified = base.isUnidentified(); } @@ -135,6 +140,7 @@ public class IncomingTextMessage implements Parcelable { this.push = fragments.get(0).isPush(); this.subscriptionId = fragments.get(0).getSubscriptionId(); this.expiresInMillis = fragments.get(0).getExpiresIn(); + this.revealDuration = fragments.get(0).getRevealDuration(); this.unidentified = fragments.get(0).isUnidentified(); } @@ -152,6 +158,7 @@ public class IncomingTextMessage implements Parcelable { this.push = true; this.subscriptionId = -1; this.expiresInMillis = 0; + this.revealDuration = 0; this.unidentified = false; } @@ -163,6 +170,10 @@ public class IncomingTextMessage implements Parcelable { return expiresInMillis; } + public long getRevealDuration() { + return revealDuration; + } + public long getSentTimestampMillis() { return sentTimestampMillis; } @@ -269,6 +280,8 @@ public class IncomingTextMessage implements Parcelable { out.writeParcelable(groupId, flags); out.writeInt(push ? 1 : 0); out.writeInt(subscriptionId); + out.writeLong(expiresInMillis); + out.writeLong(revealDuration); out.writeInt(unidentified ? 1 : 0); } } diff --git a/src/org/thoughtcrime/securesms/util/GroupUtil.java b/src/org/thoughtcrime/securesms/util/GroupUtil.java index 2ca0a710ab..71f8baf138 100644 --- a/src/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/src/org/thoughtcrime/securesms/util/GroupUtil.java @@ -73,7 +73,7 @@ public class GroupUtil { .setType(GroupContext.Type.QUIT) .build(); - return Optional.of(new OutgoingGroupMediaMessage(groupRecipient, groupContext, null, System.currentTimeMillis(), 0, null, Collections.emptyList(), Collections.emptyList())); + return Optional.of(new OutgoingGroupMediaMessage(groupRecipient, groupContext, null, System.currentTimeMillis(), 0, 0, null, Collections.emptyList(), Collections.emptyList())); } diff --git a/src/org/thoughtcrime/securesms/util/IdentityUtil.java b/src/org/thoughtcrime/securesms/util/IdentityUtil.java index d6d66ce9e5..6d00ed29ee 100644 --- a/src/org/thoughtcrime/securesms/util/IdentityUtil.java +++ b/src/org/thoughtcrime/securesms/util/IdentityUtil.java @@ -78,7 +78,7 @@ public class IdentityUtil { SignalServiceGroup group = new SignalServiceGroup(groupRecord.getId()); if (remote) { - IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.of(group), 0, false); + IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.of(group), 0, 0, false); if (verified) incoming = new IncomingIdentityVerifiedMessage(incoming); else incoming = new IncomingIdentityDefaultMessage(incoming); @@ -98,7 +98,7 @@ public class IdentityUtil { } if (remote) { - IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.absent(), 0, false); + IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.absent(), 0, 0, false); if (verified) incoming = new IncomingIdentityVerifiedMessage(incoming); else incoming = new IncomingIdentityDefaultMessage(incoming); @@ -128,14 +128,14 @@ public class IdentityUtil { while ((groupRecord = reader.getNext()) != null) { if (groupRecord.getMembers().contains(recipient.getAddress()) && groupRecord.isActive()) { SignalServiceGroup group = new SignalServiceGroup(groupRecord.getId()); - IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.of(group), 0, false); + IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.of(group), 0, 0, false); IncomingIdentityUpdateMessage groupUpdate = new IncomingIdentityUpdateMessage(incoming); smsDatabase.insertMessageInbox(groupUpdate); } } - IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.absent(), 0, false); + IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.absent(), 0, 0, false); IncomingIdentityUpdateMessage individualUpdate = new IncomingIdentityUpdateMessage(incoming); Optional insertResult = smsDatabase.insertMessageInbox(individualUpdate); diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java index f0f3017b3e..bcece8c8f4 100644 --- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -183,6 +183,8 @@ public class TextSecurePreferences { private static final String MEDIA_KEYBOARD_MODE = "pref_media_keyboard_mode"; + private static final String REVEALABLE_MESSAGE_DEFAULT = "pref_revealable_message_default"; + public static boolean isScreenLockEnabled(@NonNull Context context) { return getBooleanPreference(context, SCREEN_LOCK, false); } @@ -1098,6 +1100,14 @@ public class TextSecurePreferences { return MediaKeyboardMode.valueOf(name); } + public static void setIsRevealableMessageEnabled(Context context, boolean value) { + setBooleanPreference(context, REVEALABLE_MESSAGE_DEFAULT, value); + } + + public static boolean isRevealableMessageEnabled(Context context) { + return getBooleanPreference(context, REVEALABLE_MESSAGE_DEFAULT, false); + } + public static void setBooleanPreference(Context context, String key, boolean value) { PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply(); }