From 77beeda62abfd33a36e56211fb84ea94418a04c2 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 1 Nov 2022 11:50:41 -0400 Subject: [PATCH] Add in-chat payment activation requests. Co-authored-by: Varsha --- .../securesms/BindableConversationItem.java | 2 + .../components/emoji/EmojiStrings.java | 1 + .../conversation/ConversationFragment.java | 14 ++++ .../ConversationParentFragment.java | 8 +- .../conversation/ConversationUpdateItem.java | 18 ++++- .../securesms/conversation/MenuState.java | 4 +- .../quotes/MessageQuotesBottomSheet.kt | 10 +++ .../securesms/database/MessageDatabase.java | 10 +++ .../securesms/database/MmsDatabase.java | 44 +++++++++- .../securesms/database/MmsSmsColumns.java | 16 +++- .../securesms/database/SmsDatabase.java | 1 - .../securesms/database/ThreadBodyUtil.java | 20 +++++ .../database/model/DisplayRecord.java | 9 +++ .../database/model/MessageRecord.java | 9 ++- .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/jobs/MmsDownloadJob.java | 3 +- .../jobs/PaymentNotificationSendJob.java | 2 +- .../securesms/jobs/PushMediaSendJob.java | 21 ++++- .../jobs/SendPaymentsActivatedJob.kt | 67 ++++++++++++++++ .../messages/MessageContentProcessor.java | 80 +++++++++++++++++-- .../securesms/mms/AttachmentManager.java | 57 +++++++++++-- .../securesms/mms/IncomingMediaMessage.kt | 18 ++++- .../securesms/mms/OutgoingMediaMessage.java | 8 ++ .../mms/OutgoingPaymentActivationMessages.kt | 59 ++++++++++++++ .../preferences/PaymentsHomeRepository.java | 6 +- .../RecipientHasNotEnabledPaymentsDialog.java | 24 +++--- .../securesms/util/FeatureFlags.java | 12 ++- .../securesms/util/MessageRecordUtil.kt | 6 ++ .../drawable/ic_card_activate_payments.xml | 11 +++ app/src/main/res/values/strings.xml | 29 +++++++ .../MessageBitmaskColumnTransformer.kt | 4 + .../org/signal/core/util/CursorExtensions.kt | 1 + .../api/SignalServiceMessageSender.java | 5 +- .../api/messages/SignalServiceContent.java | 22 ++++- .../messages/SignalServiceDataMessage.java | 34 +++++++- .../src/main/proto/SignalService.proto | 10 +++ 36 files changed, 595 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/SendPaymentsActivatedJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingPaymentActivationMessages.kt create mode 100644 app/src/main/res/drawable/ic_card_activate_payments.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 6ff05366b0..ca4ffcca72 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -104,6 +104,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onBlockJoinRequest(@NonNull Recipient recipient); void onRecipientNameClicked(@NonNull RecipientId target); void onInviteToSignalClicked(); + void onActivatePaymentsClicked(); + void onSendPaymentClicked(@NonNull RecipientId recipientId); /** @return true if handled, false if you want to let the normal url handling continue */ boolean onUrlClicked(@NonNull String url); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java index d6db1e4f5a..274390fb09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java @@ -9,4 +9,5 @@ public final class EmojiStrings { public static final String FILE = "\uD83D\uDCCE"; public static final String STICKER = "\u2B50"; public static final String GIFT = "\uD83C\uDF81"; + public static final String CARD = "\uD83D\uDCB3"; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 06bcea666b..84e047e409 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -142,6 +142,7 @@ import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder; import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment; import org.thoughtcrime.securesms.messagerequests.MessageRequestState; import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel; +import org.thoughtcrime.securesms.mms.AttachmentManager; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.PartAuthority; @@ -149,6 +150,7 @@ import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.TextSlide; import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile; import org.thoughtcrime.securesms.notifications.v2.ConversationId; +import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.providers.BlobProvider; @@ -174,6 +176,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.HtmlUtil; import org.thoughtcrime.securesms.util.LifecycleDisposable; import org.thoughtcrime.securesms.util.MessageRecordUtil; +import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; @@ -2104,6 +2107,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect conversationViewModel.markGiftBadgeRevealed(messageRecord.getId()); } } + + @Override + public void onActivatePaymentsClicked() { + Intent intent = new Intent(requireContext(), PaymentsActivity.class); + startActivity(intent); + } + + @Override + public void onSendPaymentClicked(@NonNull RecipientId recipientId) { + AttachmentManager.selectPayment(ConversationFragment.this, recipient.get()); + } } private boolean isUnopenedGift(View itemView, MessageRecord messageRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 448969a2f9..839e03598e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -249,6 +249,7 @@ import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.notifications.v2.ConversationId; import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog; +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView; import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment; @@ -294,6 +295,7 @@ import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.MessageUtil; import org.thoughtcrime.securesms.util.PlayStoreUtil; +import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SmsUtil; @@ -1217,11 +1219,7 @@ public class ConversationParentFragment extends Fragment AttachmentManager.selectLocation(this, PICK_LOCATION, getSendButtonColor(sendButton.getSelectedSendType())); break; case PAYMENT: - if (ExpiringProfileCredentialUtil.isValid(recipient.get().getExpiringProfileKeyCredential())) { - AttachmentManager.selectPayment(this, recipient.getId()); - } else { - CanNotSendPaymentDialog.show(requireActivity()); - } + AttachmentManager.selectPayment(this, recipient.get()); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 04179ea96b..184d33e024 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -544,7 +544,23 @@ public final class ConversationUpdateItem extends FrameLayout }); actionButton.setText(R.string.ConversationActivity__invite_to_signal); - } else { + } else if (conversationMessage.getMessageRecord().isRequestToActivatePayments() && !conversationMessage.getMessageRecord().isOutgoing() && !SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) { + actionButton.setText(R.string.ConversationUpdateItem_activate_payments); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onActivatePaymentsClicked(); + } + }); + } else if (conversationMessage.getMessageRecord().isPaymentsActivated() && !conversationMessage.getMessageRecord().isOutgoing()) { + actionButton.setText(R.string.ConversationUpdateItem_send_payment); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onSendPaymentClicked(conversationMessage.getMessageRecord().getIndividualRecipient().getId()); + } + }); + } else{ actionButton.setVisibility(GONE); actionButton.setOnClickListener(null); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index 8188b9ecfe..a12b2d7b02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -205,7 +205,9 @@ final class MenuState { messageRecord.isChatSessionRefresh() || messageRecord.isInMemoryMessageRecord() || messageRecord.isChangeNumber() || - messageRecord.isBoostRequest(); + messageRecord.isBoostRequest() || + messageRecord.isRequestToActivatePayments() || + messageRecord.isPaymentsActivated(); } private final static class Builder { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt index 7b7c5ccffd..a1f62fe6ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt @@ -228,6 +228,16 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() { dismiss() getAdapterListener().onViewGiftBadgeClicked(messageRecord) } + + override fun onActivatePaymentsClicked() { + dismiss() + getAdapterListener().onActivatePaymentsClicked() + } + + override fun onSendPaymentClicked(recipientId: RecipientId) { + dismiss() + getAdapterListener().onSendPaymentClicked(recipientId) + } } interface Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index b0f4aceb47..16b2256edc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -12,6 +12,7 @@ import com.google.android.mms.pdu_alt.NotificationInd; import net.zetetic.database.sqlcipher.SQLiteStatement; +import org.signal.core.util.CursorExtensionsKt; import org.signal.core.util.CursorUtil; import org.signal.core.util.SQLiteDatabaseExtensionsKt; import org.signal.core.util.SqlUtil; @@ -517,6 +518,15 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns, return data; } + public List getIncomingPaymentRequestThreads() { + Cursor cursor = SQLiteDatabaseExtensionsKt.select(getReadableDatabase(), "DISTINCT " + THREAD_ID) + .from(getTableName()) + .where("(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_INBOX_TYPE + " AND (" + getTypeField() + " & ?) != 0", Types.SPECIAL_TYPE_ACTIVATE_PAYMENTS_REQUEST) + .run(); + + return CursorExtensionsKt.readToList(cursor, c -> CursorUtil.requireLong(c, THREAD_ID)); + } + @Override public void remapRecipient(@NonNull RecipientId fromId, @NonNull RecipientId toId) { ContentValues values = new ContentValues(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 03d8407c27..7d10528f41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -78,6 +78,8 @@ import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.OutgoingPaymentsActivatedMessages; +import org.thoughtcrime.securesms.mms.OutgoingRequestToActivatePaymentMessages; import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; import org.thoughtcrime.securesms.mms.QuoteModel; import org.thoughtcrime.securesms.mms.SlideDeck; @@ -1794,6 +1796,10 @@ public class MmsDatabase extends MessageDatabase { return new OutgoingGroupUpdateMessage(recipient, new MessageGroupContext(body, Types.isGroupV2(outboxType)), attachments, timestamp, 0, false, quote, contacts, previews, mentions); } else if (Types.isExpirationTimerUpdate(outboxType)) { return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn); + } else if (Types.isRequestToActivatePayments(outboxType)) { + return new OutgoingRequestToActivatePaymentMessages(recipient, timestamp, expiresIn); + } else if (Types.isPaymentsActivated(outboxType)) { + return new OutgoingPaymentsActivatedMessages(recipient, timestamp, expiresIn); } GiftBadge giftBadge = null; @@ -1987,7 +1993,7 @@ public class MmsDatabase extends MessageDatabase { updateThread); boolean isNotStoryGroupReply = retrieved.getParentStoryId() == null || !retrieved.getParentStoryId().isGroupReply(); - if (!Types.isExpirationTimerUpdate(mailbox) && !retrieved.getStoryType().isStory() && isNotStoryGroupReply) { + if (!Types.isPaymentsActivated(mailbox) && !Types.isRequestToActivatePayments(mailbox) && !Types.isExpirationTimerUpdate(mailbox) && !retrieved.getStoryType().isStory() && isNotStoryGroupReply) { boolean incrementUnreadMentions = !retrieved.getMentions().isEmpty() && retrieved.getMentions().stream().anyMatch(m -> m.getRecipientId().equals(Recipient.self().getId())); SignalDatabase.threads().incrementUnread(threadId, 1, incrementUnreadMentions ? 1 : 0); SignalDatabase.threads().update(threadId, true); @@ -2013,6 +2019,14 @@ public class MmsDatabase extends MessageDatabase { type |= Types.EXPIRATION_TIMER_UPDATE_BIT; } + if (retrieved.isActivatePaymentsRequest()) { + type |= Types.SPECIAL_TYPE_ACTIVATE_PAYMENTS_REQUEST; + } + + if (retrieved.isPaymentsActivated()) { + type |= Types.SPECIAL_TYPE_PAYMENTS_ACTIVATED; + } + return insertMessageInbox(retrieved, contentLocation, threadId, type); } @@ -2044,6 +2058,20 @@ public class MmsDatabase extends MessageDatabase { type |= Types.SPECIAL_TYPE_GIFT_BADGE; } + if (retrieved.isActivatePaymentsRequest()) { + if (hasSpecialType) { + throw new MmsException("Cannot insert message with multiple special types."); + } + type |= Types.SPECIAL_TYPE_ACTIVATE_PAYMENTS_REQUEST; + } + + if (retrieved.isPaymentsActivated()) { + if (hasSpecialType) { + throw new MmsException("Cannot insert message with multiple special types."); + } + type |= Types.SPECIAL_TYPE_PAYMENTS_ACTIVATED; + } + return insertMessageInbox(retrieved, "", threadId, type); } @@ -2211,6 +2239,20 @@ public class MmsDatabase extends MessageDatabase { type |= Types.SPECIAL_TYPE_GIFT_BADGE; } + if (message.isRequestToActivatePayments()) { + if (hasSpecialType) { + throw new MmsException("Cannot insert message with multiple special types."); + } + type |= Types.SPECIAL_TYPE_ACTIVATE_PAYMENTS_REQUEST; + } + + if (message.isPaymentsActivated()) { + if (hasSpecialType) { + throw new MmsException("Cannot insert message with multiple special types."); + } + type |= Types.SPECIAL_TYPE_PAYMENTS_ACTIVATED; + } + Map earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.getSentTimeMillis()); ContentValues contentValues = new ContentValues(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 0d49bdf915..c65eb82cff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -140,9 +140,11 @@ public interface MmsSmsColumns { protected static final long ENCRYPTION_REMOTE_LEGACY_BIT = 0x02000000; // Special message types - public static final long SPECIAL_TYPES_MASK = 0xF00000000L; - public static final long SPECIAL_TYPE_STORY_REACTION = 0x100000000L; - public static final long SPECIAL_TYPE_GIFT_BADGE = 0x200000000L; + public static final long SPECIAL_TYPES_MASK = 0xF00000000L; + public static final long SPECIAL_TYPE_STORY_REACTION = 0x100000000L; + public static final long SPECIAL_TYPE_GIFT_BADGE = 0x200000000L; + protected static final long SPECIAL_TYPE_ACTIVATE_PAYMENTS_REQUEST = 0x400000000L; + protected static final long SPECIAL_TYPE_PAYMENTS_ACTIVATED = 0x800000000L; public static boolean isStoryReaction(long type) { return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_STORY_REACTION; @@ -152,6 +154,14 @@ public interface MmsSmsColumns { return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_GIFT_BADGE; } + public static boolean isRequestToActivatePayments(long type) { + return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_ACTIVATE_PAYMENTS_REQUEST; + } + + public static boolean isPaymentsActivated(long type) { + return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_PAYMENTS_ACTIVATED; + } + public static boolean isDraftMessageType(long type) { return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 2595c013b6..c22bd0ddb7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -39,7 +39,6 @@ import org.signal.core.util.SQLiteDatabaseExtensionsKt; import org.signal.core.util.SqlUtil; import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.util.Pair; -import org.thoughtcrime.securesms.components.settings.app.chats.sms.SmsExportState; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet; import org.thoughtcrime.securesms.database.documents.NetworkFailure; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java index 1e17083646..94b389c5cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java @@ -52,6 +52,10 @@ public final class ThreadBodyUtil { return String.format("%s %s", EmojiStrings.GIFT, getGiftSummary(context, record)); } else if (MessageRecordUtil.isStoryReaction(record)) { return getStoryReactionSummary(context, record); + } else if (MessageRecordUtil.isPaymentActivationRequest(record)) { + return String.format("%s %s", EmojiStrings.CARD, getPaymentActivationRequestSummary(context, record)); + } else if (MessageRecordUtil.isPaymentsActivated(record)) { + return String.format("%s %s", EmojiStrings.CARD, getPaymentActivatedSummary(context, record)); } boolean hasImage = false; @@ -94,6 +98,22 @@ public final class ThreadBodyUtil { return context.getString(R.string.ThreadRecord__reacted_s_to_your_story, messageRecord.getDisplayBody(context)); } } + + private static @NonNull String getPaymentActivationRequestSummary(@NonNull Context context, @NonNull MessageRecord messageRecord) { + if (messageRecord.isOutgoing()) { + return context.getString(R.string.ThreadRecord_you_sent_request); + } else { + return context.getString(R.string.ThreadRecord_wants_you_to_activate_payments, messageRecord.getRecipient().getShortDisplayName(context)); + } + } + + private static @NonNull String getPaymentActivatedSummary(@NonNull Context context, @NonNull MessageRecord messageRecord) { + if (messageRecord.isOutgoing()) { + return context.getString(R.string.ThreadRecord_you_activated_payments); + } else { + return context.getString(R.string.ThreadRecord_can_accept_payments, messageRecord.getRecipient().getShortDisplayName(context)); + } + } private static @NonNull String format(@NonNull Context context, @NonNull MessageRecord record, @NonNull String emoji, @StringRes int defaultStringRes) { return String.format("%s %s", emoji, getBodyOrDefault(context, record, defaultStringRes)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index 2ec2157d44..0a522500e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -22,6 +22,7 @@ import android.text.SpannableString; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.recipients.Recipient; @@ -231,4 +232,12 @@ public abstract class DisplayRecord { public boolean isPendingInsecureSmsFallback() { return SmsDatabase.Types.isPendingInsecureSmsFallbackType(type); } + + public boolean isRequestToActivatePayments() { + return SmsDatabase.Types.isRequestToActivatePayments(type); + } + + public boolean isPaymentsActivated() { + return SmsDatabase.Types.isPaymentsActivated(type); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 3d309e4aed..e7aac280bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -240,6 +240,12 @@ public abstract class MessageRecord extends DisplayRecord { int messageResource = SignalStore.misc().getSmsExportPhase().isSmsSupported() ? R.string.MessageRecord__you_will_no_longer_be_able_to_send_sms_messages_from_signal_soon : R.string.MessageRecord__you_can_no_longer_send_sms_messages_in_signal; return fromRecipient(getIndividualRecipient(), r -> context.getString(messageResource, r.getDisplayName(context)), R.drawable.ic_update_info_16); + } else if (isRequestToActivatePayments()) { + return isOutgoing() ? fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_sent_request, r.getShortDisplayName(context)), R.drawable.ic_card_activate_payments) + : fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_wants_you_to_activate_payments, r.getShortDisplayName(context)), R.drawable.ic_card_activate_payments); + } else if (isPaymentsActivated()) { + return isOutgoing() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_activated_payments), R.drawable.ic_card_activate_payments) + : fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_can_accept_payments, r.getShortDisplayName(context)), R.drawable.ic_card_activate_payments); } return null; @@ -570,7 +576,8 @@ public abstract class MessageRecord extends DisplayRecord { return isGroupAction() || isJoined() || isExpirationTimerUpdate() || isCallLog() || isEndSession() || isIdentityUpdate() || isIdentityVerified() || isIdentityDefault() || isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() || - isChangeNumber() || isBoostRequest() || isThreadMergeEventType() || isSmsExportType(); + isChangeNumber() || isBoostRequest() || isThreadMergeEventType() || isSmsExportType() || + isRequestToActivatePayments() || isPaymentsActivated(); } public boolean isMediaPending() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index d8b80a4026..d73855d83c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -102,6 +102,7 @@ public final class JobManagerFactories { put(ForceUpdateGroupV2Job.KEY, new ForceUpdateGroupV2Job.Factory()); put(ForceUpdateGroupV2WorkerJob.KEY, new ForceUpdateGroupV2WorkerJob.Factory()); put(GiftSendJob.KEY, new GiftSendJob.Factory()); + put(SendPaymentsActivatedJob.KEY, new SendPaymentsActivatedJob.Factory()); put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory()); put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory()); put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java index 62b4bf8462..124b3b4303 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java @@ -249,8 +249,7 @@ public class MmsDownloadJob extends BaseJob { List recipients = new ArrayList<>(members); group = Optional.of(SignalDatabase.groups().getOrCreateMmsGroupForMembers(recipients)); } - - IncomingMediaMessage message = new IncomingMediaMessage(from, group, body, TimeUnit.SECONDS.toMillis(retrieved.getDate()), -1, System.currentTimeMillis(), attachments, subscriptionId, 0, false, false, false, Optional.of(sharedContacts)); + IncomingMediaMessage message = new IncomingMediaMessage(from, group, body, TimeUnit.SECONDS.toMillis(retrieved.getDate()), -1, System.currentTimeMillis(), attachments, subscriptionId, 0, false, false, false, Optional.of(sharedContacts), false, false); Optional insertResult = database.insertMessageInbox(message, contentLocation, threadId); if (insertResult.isPresent()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentNotificationSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentNotificationSendJob.java index 9b4c412f3c..977a48c59d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentNotificationSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentNotificationSendJob.java @@ -107,7 +107,7 @@ public final class PaymentNotificationSendJob extends BaseJob { } SignalServiceDataMessage dataMessage = SignalServiceDataMessage.newBuilder() - .withPayment(new SignalServiceDataMessage.Payment(new SignalServiceDataMessage.PaymentNotification(payment.getReceipt(), payment.getNote()))) + .withPayment(new SignalServiceDataMessage.Payment(new SignalServiceDataMessage.PaymentNotification(payment.getReceipt(), payment.getNote()), null)) .build(); SendMessageResult sendMessageResult = messageSender.sendDataMessage(address, unidentifiedAccess, ContentHint.DEFAULT, dataMessage, IndividualSendEvents.EMPTY, false, recipient.needsPniSignature()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java index d16b85bd49..2829905352 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -46,6 +46,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import java.io.FileNotFoundException; import java.io.IOException; @@ -212,6 +213,7 @@ public class PushMediaSendJob extends PushSendJob { List sharedContacts = getSharedContactsFor(message); List previews = getPreviewsFor(message); SignalServiceDataMessage.GiftBadge giftBadge = getGiftBadgeFor(message); + SignalServiceDataMessage.Payment payment = getPaymentActivation(message); SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder() .withBody(message.getBody()) .withAttachments(serviceAttachments) @@ -223,7 +225,8 @@ public class PushMediaSendJob extends PushSendJob { .withSharedContacts(sharedContacts) .withPreviews(previews) .withGiftBadge(giftBadge) - .asExpirationUpdate(message.isExpirationUpdate()); + .asExpirationUpdate(message.isExpirationUpdate()) + .withPayment(payment); if (message.getParentStoryId() != null) { try { @@ -278,6 +281,22 @@ public class PushMediaSendJob extends PushSendJob { } } + private SignalServiceDataMessage.Payment getPaymentActivation(OutgoingMediaMessage message) { + SignalServiceProtos.DataMessage.Payment.Activation.Type type = null; + + if (message.isRequestToActivatePayments()) { + type = SignalServiceProtos.DataMessage.Payment.Activation.Type.REQUEST; + } else if (message.isPaymentsActivated()) { + type = SignalServiceProtos.DataMessage.Payment.Activation.Type.ACTIVATED; + } + + if (type != null) { + return new SignalServiceDataMessage.Payment(null, new SignalServiceDataMessage.PaymentActivation(type)); + } else { + return null; + } + } + public static long getMessageId(@NonNull Data data) { return data.getLong(KEY_MESSAGE_ID); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendPaymentsActivatedJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendPaymentsActivatedJob.kt new file mode 100644 index 0000000000..39d43f059a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendPaymentsActivatedJob.kt @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.mms.OutgoingPaymentsActivatedMessages +import org.thoughtcrime.securesms.sms.MessageSender + +/** + * Send payments activated message to all recipients of payment activation request + */ +class SendPaymentsActivatedJob(parameters: Parameters) : BaseJob(parameters) { + + companion object { + private val TAG = Log.tag(SendPaymentsActivatedJob::class.java) + + const val KEY = "SendPaymentsActivatedJob" + } + + constructor() : this(parameters = Parameters.Builder().build()) + + override fun serialize(): Data = Data.Builder().build() + + override fun getFactoryKey(): String = KEY + + @Suppress("UsePropertyAccessSyntax") + override fun onRun() { + if (!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) { + Log.w(TAG, "Payments aren't enabled, not going to attempt to send activation messages.") + return + } + + val threadIds: List = SignalDatabase.mms.getIncomingPaymentRequestThreads() + + for (threadId in threadIds) { + val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId) + if (recipient != null) { + MessageSender.send( + context, + OutgoingPaymentsActivatedMessages(recipient, System.currentTimeMillis(), 0), + threadId, + false, + null, + null + ) + } else { + Log.w(TAG, "Unable to send activation message for thread: $threadId") + } + } + } + + override fun onShouldRetry(e: Exception): Boolean { + return false + } + + override fun onFailure() { + Log.w(TAG, "Failed to submit send of payments activated messages") + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): SendPaymentsActivatedJob { + return SendPaymentsActivatedJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index ce1020c8f2..94764ce5bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -295,6 +295,8 @@ public final class MessageContentProcessor { else if (message.getReaction().isPresent() && message.getStoryContext().isPresent()) messageId = handleStoryReaction(content, message, senderRecipient); else if (message.getReaction().isPresent()) messageId = handleReaction(content, message, senderRecipient); else if (message.getRemoteDelete().isPresent()) messageId = handleRemoteDelete(content, message, senderRecipient); + else if (message.isActivatePaymentsRequest()) messageId = handlePaymentActivation(content, message, smsMessageId, senderRecipient, receivedTime, true, false); + else if (message.isPaymentsActivated()) messageId = handlePaymentActivation(content, message, smsMessageId, senderRecipient, receivedTime, false, true); else if (message.getPayment().isPresent()) handlePayment(content, message, senderRecipient); else if (message.getStoryContext().isPresent()) messageId = handleStoryReply(content, message, senderRecipient, receivedTime); else if (message.getGiftBadge().isPresent()) messageId = handleGiftMessage(content, message, senderRecipient, threadRecipient, receivedTime); @@ -809,6 +811,60 @@ public final class MessageContentProcessor { } } + /** + * @param isActivatePaymentsRequest True if payments activation request message. + * @param isPaymentsActivated True if payments activated message. + * @throws StorageFailedException + */ + private @Nullable MessageId handlePaymentActivation(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message, + @NonNull Optional smsMessageId, + @NonNull Recipient senderRecipient, + long receivedTime, + boolean isActivatePaymentsRequest, + boolean isPaymentsActivated) + throws StorageFailedException + { + try { + MessageDatabase database = SignalDatabase.mms(); + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(senderRecipient.getId(), + content.getTimestamp(), + content.getServerReceivedTimestamp(), + receivedTime, + StoryType.NONE, + null, + false, + -1, + TimeUnit.SECONDS.toMillis(message.getExpiresInSeconds()), + false, + false, + content.isNeedsReceipt(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + content.getServerUuid(), + null, + isActivatePaymentsRequest, + isPaymentsActivated); + + Optional insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); + if (smsMessageId.isPresent()) { + SignalDatabase.sms().deleteMessage(smsMessageId.get()); + } + + if (insertResult.isPresent()) { + return new MessageId(insertResult.get().getMessageId(), true); + } + } catch (MmsException e) { + throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice()); + } + return null; + } /** * @param sideEffect True if the event is side effect of a different message, false if the message itself was an expiration update. @@ -862,7 +918,9 @@ public final class MessageContentProcessor { Optional.empty(), Optional.empty(), content.getServerUuid(), - null); + null, + false, + false); Optional insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -1423,7 +1481,9 @@ public final class MessageContentProcessor { Optional.empty(), Optional.empty(), content.getServerUuid(), - null); + null, + false, + false); insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -1587,7 +1647,9 @@ public final class MessageContentProcessor { Optional.empty(), Optional.empty(), content.getServerUuid(), - null); + null, + false, + false); Optional insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -1690,7 +1752,9 @@ public final class MessageContentProcessor { getMentions(message.getMentions()), Optional.empty(), content.getServerUuid(), - null); + null, + false, + false); Optional insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -1768,7 +1832,9 @@ public final class MessageContentProcessor { Optional.empty(), Optional.empty(), content.getServerUuid(), - giftBadge); + giftBadge, + false, + false); insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); } catch (MmsException e) { @@ -1832,7 +1898,9 @@ public final class MessageContentProcessor { mentions, sticker, content.getServerUuid(), - null); + null, + false, + false); insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index d6a8239ee6..37c3d654df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -39,7 +39,10 @@ import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + import org.signal.core.util.ThreadUtil; +import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; @@ -52,27 +55,36 @@ import org.thoughtcrime.securesms.components.location.SignalPlace; import org.thoughtcrime.securesms.conversation.MessageSendType; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.giph.ui.GiphyActivity; import org.thoughtcrime.securesms.maps.PlacePickerActivity; import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory; import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Fragment; import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity; +import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog; +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; +import org.thoughtcrime.securesms.payments.PaymentsAddressException; import org.thoughtcrime.securesms.payments.create.CreatePaymentFragmentArgs; import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity; +import org.thoughtcrime.securesms.payments.preferences.RecipientHasNotEnabledPaymentsDialog; import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener; import org.thoughtcrime.securesms.util.concurrent.SettableFuture; import org.thoughtcrime.securesms.util.views.Stub; +import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil; import java.io.IOException; import java.util.Collections; @@ -81,6 +93,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; public class AttachmentManager { @@ -419,11 +432,45 @@ public class AttachmentManager { fragment.startActivityForResult(intent, requestCode); } - public static void selectPayment(@NonNull Fragment fragment, @NonNull RecipientId recipientId) { - Intent intent = new Intent(fragment.requireContext(), PaymentsActivity.class); - intent.putExtra(PaymentsActivity.EXTRA_PAYMENTS_STARTING_ACTION, R.id.action_directly_to_createPayment); - intent.putExtra(PaymentsActivity.EXTRA_STARTING_ARGUMENTS, new CreatePaymentFragmentArgs.Builder(new PayeeParcelable(recipientId)).build().toBundle()); - fragment.startActivity(intent); + public static void selectPayment(@NonNull Fragment fragment, @NonNull Recipient recipient) { + if (!ExpiringProfileCredentialUtil.isValid(recipient.getExpiringProfileKeyCredential())) { + CanNotSendPaymentDialog.show(fragment.requireContext()); + return; + } + + SimpleTask.run(fragment.getViewLifecycleOwner().getLifecycle(), + () -> { + try { + return ProfileUtil.getAddressForRecipient(recipient); + } catch (IOException | PaymentsAddressException e) { + Log.w(TAG, "Could not get address for recipient: ", e); + return null; + } + }, + (address) -> { + if (address != null) { + Intent intent = new Intent(fragment.requireContext(), PaymentsActivity.class); + intent.putExtra(PaymentsActivity.EXTRA_PAYMENTS_STARTING_ACTION, R.id.action_directly_to_createPayment); + intent.putExtra(PaymentsActivity.EXTRA_STARTING_ARGUMENTS, new CreatePaymentFragmentArgs.Builder(new PayeeParcelable(recipient.getId())).build().toBundle()); + fragment.startActivity(intent); + } else if (FeatureFlags.paymentsRequestActivateFlow()) { + showRequestToActivatePayments(fragment.requireContext(), recipient); + } else { + RecipientHasNotEnabledPaymentsDialog.show(fragment.requireContext()); + } + }); + } + + public static void showRequestToActivatePayments(@NonNull Context context, @NonNull Recipient recipient) { + new MaterialAlertDialogBuilder(context) + .setTitle(context.getString(R.string.AttachmentManager__not_activated_payments, recipient.getShortDisplayName(context))) + .setMessage(context.getString(R.string.AttachmentManager__request_to_activate_payments)) + .setPositiveButton(context.getString(R.string.AttachmentManager__send_request), (dialog, which) -> { + OutgoingRequestToActivatePaymentMessages outgoingMessage = new OutgoingRequestToActivatePaymentMessages(recipient, System.currentTimeMillis(), 0); + MessageSender.send(context, outgoingMessage, SignalDatabase.threads().getOrCreateThreadIdFor(recipient), false, null, null); + }) + .setNegativeButton(context.getString(R.string.AttachmentManager__cancel), null) + .show(); } private @Nullable Uri getSlideUri() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt index e620f64b24..e70e84509a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt @@ -38,7 +38,9 @@ class IncomingMediaMessage( sharedContacts: List = emptyList(), linkPreviews: List = emptyList(), mentions: List = emptyList(), - val giftBadge: GiftBadge? = null + val giftBadge: GiftBadge? = null, + val isActivatePaymentsRequest: Boolean = false, + val isPaymentsActivated: Boolean = false ) { val attachments: List = ArrayList(attachments) @@ -61,7 +63,9 @@ class IncomingMediaMessage( expirationUpdate: Boolean, viewOnce: Boolean, unidentified: Boolean, - sharedContacts: Optional> + sharedContacts: Optional>, + activatePaymentsRequest: Boolean, + paymentsActivated: Boolean ) : this( from = from, groupId = groupId.orElse(null), @@ -79,6 +83,8 @@ class IncomingMediaMessage( serverGuid = null, attachments = attachments?.let { ArrayList(it) } ?: emptyList(), sharedContacts = ArrayList(sharedContacts.orElse(emptyList())), + isActivatePaymentsRequest = activatePaymentsRequest, + isPaymentsActivated = paymentsActivated ) constructor( @@ -103,7 +109,9 @@ class IncomingMediaMessage( mentions: Optional>, sticker: Optional, serverGuid: String?, - giftBadge: GiftBadge? + giftBadge: GiftBadge?, + activatePaymentsRequest: Boolean, + paymentsActivated: Boolean ) : this( from = from, groupId = if (group.isPresent) GroupId.v2(group.get().masterKey) else null, @@ -126,6 +134,8 @@ class IncomingMediaMessage( sharedContacts = sharedContacts.orElse(emptyList()), linkPreviews = linkPreviews.orElse(emptyList()), mentions = mentions.orElse(emptyList()), - giftBadge = giftBadge + giftBadge = giftBadge, + isActivatePaymentsRequest = activatePaymentsRequest, + isPaymentsActivated = paymentsActivated ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java index e9351e7c62..d6761478c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java @@ -193,6 +193,14 @@ public class OutgoingMediaMessage { return false; } + public boolean isRequestToActivatePayments() { + return false; + } + + public boolean isPaymentsActivated() { + return false; + } + public long getSentTimeMillis() { return sentTimeMillis; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingPaymentActivationMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingPaymentActivationMessages.kt new file mode 100644 index 0000000000..4833e22913 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingPaymentActivationMessages.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.mms + +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.recipients.Recipient +import java.util.LinkedList + +/** + * Specialized message sent to request someone activate payments. + */ +class OutgoingRequestToActivatePaymentMessages( + recipient: Recipient, + sentTimeMillis: Long, + expiresIn: Long +) : OutgoingSecureMediaMessage( + recipient, + "", + LinkedList(), + sentTimeMillis, + ThreadDatabase.DistributionTypes.CONVERSATION, + expiresIn, + false, + StoryType.NONE, + null, + false, + null, emptyList(), emptyList(), emptyList(), + null +) { + override fun isRequestToActivatePayments(): Boolean { + return true + } +} + +/** + * Specialized message sent to indicate you activated payments. Intended to only + * be sent to those that sent requests prior to activation. + */ +class OutgoingPaymentsActivatedMessages( + recipient: Recipient, + sentTimeMillis: Long, + expiresIn: Long +) : OutgoingSecureMediaMessage( + recipient, + "", + LinkedList(), + sentTimeMillis, + ThreadDatabase.DistributionTypes.CONVERSATION, + expiresIn, + false, + StoryType.NONE, + null, + false, + null, emptyList(), emptyList(), emptyList(), + null +) { + override fun isPaymentsActivated(): Boolean { + return true + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeRepository.java index aef89b5c44..2a434d9ffb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeRepository.java @@ -8,6 +8,7 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob; import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.jobs.SendPaymentsActivatedJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.AsynchronousCallback; import org.thoughtcrime.securesms.util.ProfileUtil; @@ -25,7 +26,10 @@ public class PaymentsHomeRepository { SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(true); try { ProfileUtil.uploadProfile(ApplicationDependencies.getApplication()); - ApplicationDependencies.getJobManager().add(PaymentLedgerUpdateJob.updateLedger()); + ApplicationDependencies.getJobManager() + .startChain(PaymentLedgerUpdateJob.updateLedger()) + .then(new SendPaymentsActivatedJob()) + .enqueue(); callback.onComplete(null); } catch (PaymentsRegionException e) { SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/RecipientHasNotEnabledPaymentsDialog.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/RecipientHasNotEnabledPaymentsDialog.java index 53cc102c27..0e9ce982c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/RecipientHasNotEnabledPaymentsDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/RecipientHasNotEnabledPaymentsDialog.java @@ -1,11 +1,12 @@ package org.thoughtcrime.securesms.payments.preferences; -import android.app.AlertDialog; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + import org.thoughtcrime.securesms.R; /** @@ -19,16 +20,17 @@ public final class RecipientHasNotEnabledPaymentsDialog { public static void show(@NonNull Context context) { show(context, null); } + public static void show(@NonNull Context context, @Nullable Runnable onDismissed) { - new AlertDialog.Builder(context).setTitle(R.string.ConfirmPaymentFragment__invalid_recipient) - .setMessage(R.string.ConfirmPaymentFragment__this_person_has_not_activated_payments) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - dialog.dismiss(); - if (onDismissed != null) { - onDismissed.run(); - } - }) - .setCancelable(false) - .show(); + new MaterialAlertDialogBuilder(context).setTitle(R.string.ConfirmPaymentFragment__invalid_recipient) + .setMessage(R.string.ConfirmPaymentFragment__this_person_has_not_activated_payments) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + dialog.dismiss(); + if (onDismissed != null) { + onDismissed.run(); + } + }) + .setCancelable(false) + .show(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index cd3e3b6594..8a2daa1d82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -104,6 +104,7 @@ public final class FeatureFlags { private static final String HIDE_CONTACTS = "android.hide.contacts"; private static final String SMS_EXPORT_MEGAPHONE_DELAY_DAYS = "android.smsExport.megaphoneDelayDays.2"; public static final String CREDIT_CARD_PAYMENTS = "android.credit.card.payments"; + private static final String PAYMENTS_REQUEST_ACTIVATE_FLOW = "android.payments.requestActivateFlow"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -159,7 +160,8 @@ public final class FeatureFlags { STORIES_LOCALE, HIDE_CONTACTS, SMS_EXPORT_MEGAPHONE_DELAY_DAYS, - CREDIT_CARD_PAYMENTS + CREDIT_CARD_PAYMENTS, + PAYMENTS_REQUEST_ACTIVATE_FLOW ); @VisibleForTesting @@ -222,7 +224,8 @@ public final class FeatureFlags { RECIPIENT_MERGE_V2, STORIES, SMS_EXPORT_MEGAPHONE_DELAY_DAYS, - CREDIT_CARD_PAYMENTS + CREDIT_CARD_PAYMENTS, + PAYMENTS_REQUEST_ACTIVATE_FLOW ); /** @@ -570,6 +573,11 @@ public final class FeatureFlags { return getBoolean(CREDIT_CARD_PAYMENTS, Environment.IS_STAGING); } + /** Whether client supports sending a request to another to activate payments */ + public static boolean paymentsRequestActivateFlow() { + return getBoolean(PAYMENTS_REQUEST_ACTIVATE_FLOW, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt index db12288b71..2662379a65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt @@ -44,6 +44,12 @@ fun MessageRecord.hasThumbnail(): Boolean = fun MessageRecord.isStoryReaction(): Boolean = isMms && MmsSmsColumns.Types.isStoryReaction((this as MmsMessageRecord).type) +fun MessageRecord.isPaymentActivationRequest(): Boolean = + isMms && MmsSmsColumns.Types.isRequestToActivatePayments((this as MmsMessageRecord).type) + +fun MessageRecord.isPaymentsActivated(): Boolean = + isMms && MmsSmsColumns.Types.isPaymentsActivated((this as MmsMessageRecord).type) + fun MessageRecord.isBorderless(context: Context): Boolean { return isCaptionlessMms(context) && hasThumbnail() && diff --git a/app/src/main/res/drawable/ic_card_activate_payments.xml b/app/src/main/res/drawable/ic_card_activate_payments.xml new file mode 100644 index 0000000000..b50269debe --- /dev/null +++ b/app/src/main/res/drawable/ic_card_activate_payments.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 725eb108d7..5203664a7b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,14 @@ Signal requires the Storage permission in order to attach photos, videos, or audio, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Storage\". Signal requires Contacts permission in order to attach contact information, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Contacts\". Signal requires Location permission in order to attach a location, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Location\". + + %1$s hasn\’t activated Payments + + Do you want to send them a request to activate Payments? + + Send request + + Cancel Uploading media… @@ -1411,6 +1419,14 @@ Your message history with %1$s and their number %2$s has been merged. Your message history with %1$s and another chat that belonged to them has been merged. + + You sent %s a request to activate Payments + + %s wants you to activate Payments. Only send payments to people you trust. + + You activated Payments + + %s can now accept Payments %1$s started a group call · %2$s @@ -1904,6 +1920,14 @@ View-once media This message was deleted. You deleted this message. + + You sent a request to activate Payments + + %s wants you to activate Payments + + You activated Payments + + %s can now accept Payments %s is on Signal! Disappearing messages disabled Disappearing message time set to %s @@ -2260,6 +2284,11 @@ The disappearing message time will be set to %1$s when you message them. Donate + + Send payment + + Activate payments + Play … Pause diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt index 3d95d88e18..98fa3ca1a0 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt @@ -52,7 +52,9 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.PUSH_MESSAGE_BIT import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SECURE_MESSAGE_BIT import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SMS_EXPORT_TYPE import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SPECIAL_TYPES_MASK +import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SPECIAL_TYPE_ACTIVATE_PAYMENTS_REQUEST import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SPECIAL_TYPE_GIFT_BADGE +import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SPECIAL_TYPE_PAYMENTS_ACTIVATED import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.SPECIAL_TYPE_STORY_REACTION import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.THREAD_MERGE_TYPE import org.thoughtcrime.securesms.database.MmsSmsColumns.Types.UNSUPPORTED_MESSAGE_TYPE @@ -119,6 +121,8 @@ object MessageBitmaskColumnTransformer : ColumnTransformer { isSpecialType:${type and SPECIAL_TYPES_MASK != 0L} isStoryReaction:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_STORY_REACTION} isGiftBadge:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_GIFT_BADGE} + isRequestToActivatePayments:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_ACTIVATE_PAYMENTS_REQUEST} + isPaymentsActivated:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_PAYMENTS_ACTIVATED} """.trimIndent() return "$type

" + describe.replace(Regex("is[A-Z][A-Za-z0-9]*:false\n?"), "").replace("\n", "
") diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index d9ac13f1e7..b321dbdb07 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -78,6 +78,7 @@ fun Cursor.readToSingleLong(defaultValue: Long = 0): Long { } } +@JvmOverloads inline fun Cursor.readToList(predicate: (T) -> Boolean = { true }, mapper: (Cursor) -> T): List { val list = mutableListOf() use { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index aba393ce93..9b04ced56e 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -1028,8 +1028,11 @@ public class SignalServiceMessageSender { .setMobileCoin(mobileCoinPayment); builder.setPayment(DataMessage.Payment.newBuilder().setNotification(paymentBuilder)); - builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.PAYMENTS_VALUE, builder.getRequiredProtocolVersion())); + } else if (payment.getPaymentActivation().isPresent()) { + DataMessage.Payment.Activation.Builder activationBuilder = DataMessage.Payment.Activation.newBuilder().setType(payment.getPaymentActivation().get().getType()); + builder.setPayment(DataMessage.Payment.newBuilder().setActivation(activationBuilder)); } + builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.PAYMENTS_VALUE, builder.getRequiredProtocolVersion())); } if (message.getStoryContext().isPresent()) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index 33ffe76c6a..83597650d0 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -1240,8 +1240,12 @@ public final class SignalServiceContent { SignalServiceProtos.DataMessage.Payment payment = content.getPayment(); switch (payment.getItemCase()) { - case NOTIFICATION: return new SignalServiceDataMessage.Payment(createPaymentNotification(payment)); - default : throw new InvalidMessageStructureException("Unknown payment item"); + case NOTIFICATION: + return new SignalServiceDataMessage.Payment(createPaymentNotification(payment), null); + case ACTIVATION: + return new SignalServiceDataMessage.Payment(null, createPaymentActivation(payment)); + default: + throw new InvalidMessageStructureException("Unknown payment item"); } } @@ -1290,6 +1294,20 @@ public final class SignalServiceContent { return new SignalServiceDataMessage.PaymentNotification(payment.getMobileCoin().getReceipt().toByteArray(), payment.getNote()); } + private static SignalServiceDataMessage.PaymentActivation createPaymentActivation(SignalServiceProtos.DataMessage.Payment content) + throws InvalidMessageStructureException + { + if (!content.hasActivation() || + content.getItemCase() != SignalServiceProtos.DataMessage.Payment.ItemCase.ACTIVATION) + { + throw new InvalidMessageStructureException("Badly-formatted payment activation!"); + } + + SignalServiceProtos.DataMessage.Payment.Activation payment = content.getActivation(); + + return new SignalServiceDataMessage.PaymentActivation(payment.getType()); + } + private static List createSharedContacts(SignalServiceProtos.DataMessage content) throws InvalidMessageStructureException { if (content.getContactCount() <= 0) return null; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java index 7659534be7..3c7b0273fa 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java @@ -161,6 +161,18 @@ public class SignalServiceDataMessage { return expirationUpdate; } + public boolean isActivatePaymentsRequest() { + return getPayment().isPresent() && + getPayment().get().getPaymentActivation().isPresent() && + getPayment().get().getPaymentActivation().get().getType().equals(SignalServiceProtos.DataMessage.Payment.Activation.Type.REQUEST); + } + + public boolean isPaymentsActivated() { + return getPayment().isPresent() && + getPayment().get().getPaymentActivation().isPresent() && + getPayment().get().getPaymentActivation().get().getType().equals(SignalServiceProtos.DataMessage.Payment.Activation.Type.ACTIVATED); + } + public boolean isProfileKeyUpdate() { return profileKeyUpdate; } @@ -655,16 +667,34 @@ public class SignalServiceDataMessage { } } + public static class PaymentActivation { + private final SignalServiceProtos.DataMessage.Payment.Activation.Type type; + + public PaymentActivation(SignalServiceProtos.DataMessage.Payment.Activation.Type type) { + this.type = type; + } + + public SignalServiceProtos.DataMessage.Payment.Activation.Type getType() { + return type; + } + } + public static class Payment { private final Optional paymentNotification; + private final Optional paymentActivation; - public Payment(PaymentNotification paymentNotification) { - this.paymentNotification = Optional.of(paymentNotification); + public Payment(PaymentNotification paymentNotification, PaymentActivation paymentActivation) { + this.paymentNotification = Optional.ofNullable(paymentNotification); + this.paymentActivation = Optional.ofNullable(paymentActivation); } public Optional getPaymentNotification() { return paymentNotification; } + + public Optional getPaymentActivation() { + return paymentActivation; + } } public static class StoryContext { diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index d11e143116..3aad3d93b9 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -292,8 +292,18 @@ message DataMessage { optional string note = 2; } + message Activation { + enum Type { + REQUEST = 0; + ACTIVATED = 1; + } + + optional Type type = 1; + } + oneof Item { Notification notification = 1; + Activation activation = 2; } }