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"/> + + + + + + 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(); }