diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt index f35b15210f..8641f619d9 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt @@ -330,5 +330,6 @@ class V2ConversationItemShapeTest { override fun onMessageRequestAcceptOptionsClicked() = Unit override fun onItemDoubleClick(item: MultiselectPart) = Unit + override fun onPaymentTombstoneClicked() = Unit } } diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt index 273dd22dcb..fa558ea294 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt @@ -304,6 +304,10 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() } + override fun onPaymentTombstoneClicked() { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } + override fun onShowSafetyTips(forGroup: Boolean) { Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 2805aa41e7..04b4a86ba3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -132,5 +132,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onReportSpamLearnMoreClicked(); void onMessageRequestAcceptOptionsClicked(); void onItemDoubleClick(MultiselectPart multiselectPart); + void onPaymentTombstoneClicked(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index 3ceed7ae1a..fff4679a46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage import org.thoughtcrime.securesms.backup.v2.proto.GroupCall import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment +import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification import org.thoughtcrime.securesms.backup.v2.proto.Quote import org.thoughtcrime.securesms.backup.v2.proto.Reaction import org.thoughtcrime.securesms.backup.v2.proto.SendStatus @@ -46,22 +47,31 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailureSet import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras +import org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.payments.CryptoValueUtil +import org.thoughtcrime.securesms.payments.Direction +import org.thoughtcrime.securesms.payments.State +import org.thoughtcrime.securesms.payments.proto.PaymentMetaData import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.JsonUtils import org.whispersystems.signalservice.api.backup.MediaName import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId +import org.whispersystems.signalservice.api.payments.Money import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.push.DataMessage +import java.math.BigInteger import java.util.Optional +import java.util.UUID /** * An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them @@ -283,6 +293,22 @@ class ChatItemImportInserter( } } } + if (this.paymentNotification != null) { + followUp = { messageRowId -> + val uuid = tryRestorePayment(this, chatRecipientId) + if (uuid != null) { + db.update( + MessageTable.TABLE_NAME, + contentValuesOf( + MessageTable.BODY to uuid.toString(), + MessageTable.TYPE to ((contentValues.getAsLong(MessageTable.TYPE) and MessageTypes.SPECIAL_TYPES_MASK.inv()) or MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION) + ), + "${MessageTable.ID}=?", + SqlUtil.buildArgs(messageRowId) + ) + } + } + } if (this.standardMessage != null) { val bodyRanges = this.standardMessage.text?.bodyRanges if (!bodyRanges.isNullOrEmpty()) { @@ -370,11 +396,42 @@ class ChatItemImportInserter( this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage) this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1) this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage) + this.paymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId) } return contentValues } + private fun tryRestorePayment(chatItem: ChatItem, chatRecipientId: RecipientId): UUID? { + val paymentNotification = chatItem.paymentNotification!! + + val amount = paymentNotification.amountMob?.tryParseMoney() ?: return null + val fee = paymentNotification.feeMob?.tryParseMoney() ?: return null + + if (paymentNotification.transactionDetails?.failedTransaction != null) { + return null + } + + val transaction = paymentNotification.transactionDetails?.transaction + + val mobileCoinIdentification = transaction?.mobileCoinIdentification?.toLocal() ?: return null + + return SignalDatabase.payments.restoreFromBackup( + chatRecipientId, + transaction.timestamp ?: 0, + transaction.blockIndex ?: 0, + paymentNotification.note ?: "", + if (chatItem.outgoing != null) Direction.SENT else Direction.RECEIVED, + transaction.status.toLocalStatus(), + amount, + fee, + transaction.transaction?.toByteArray(), + transaction.receipt?.toByteArray(), + mobileCoinIdentification, + chatItem.incoming?.read ?: true + ) + } + private fun ChatItem.toReactionContentValues(messageId: Long): List { val reactions: List = when { this.standardMessage != null -> this.standardMessage.reactions @@ -551,6 +608,104 @@ class ChatItemImportInserter( this.put(MessageTable.TYPE, typeFlags) } + /** + * Add the payment notification to the chat item. + * + * Note we add a tombstone first, then post insertion update it to a proper notification + */ + private fun ContentValues.addPaymentNotification(chatItem: ChatItem, chatRecipientId: RecipientId) { + val paymentNotification = chatItem.paymentNotification!! + if (chatItem.paymentNotification.amountMob.isNullOrEmpty()) { + addPaymentTombstoneNoAmount() + return + } + val amount = paymentNotification.amountMob?.tryParseMoney() ?: return addPaymentTombstoneNoAmount() + val fee = paymentNotification.feeMob?.tryParseMoney() ?: return addPaymentTombstoneNoAmount() + + if (chatItem.paymentNotification.transactionDetails?.failedTransaction != null) { + addFailedPaymentNotification(chatItem, amount, fee, chatRecipientId) + return + } + addPaymentTombstoneNoMetadata(chatItem.paymentNotification) + } + + private fun PaymentNotification.TransactionDetails.MobileCoinTxoIdentification.toLocal(): PaymentMetaData { + return PaymentMetaData( + mobileCoinTxoIdentification = PaymentMetaData.MobileCoinTxoIdentification( + publicKey = this.publicKey, + keyImages = this.keyImages + ) + ) + } + + private fun ContentValues.addFailedPaymentNotification(chatItem: ChatItem, amount: Money, fee: Money, chatRecipientId: RecipientId) { + val uuid = SignalDatabase.payments.restoreFromBackup( + chatRecipientId, + 0, + 0, + chatItem.paymentNotification?.note ?: "", + if (chatItem.outgoing != null) Direction.SENT else Direction.RECEIVED, + State.FAILED, + amount, + fee, + null, + null, + null, + chatItem.incoming?.read ?: true + ) + if (uuid != null) { + put(MessageTable.BODY, uuid.toString()) + put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION) + } else { + addPaymentTombstoneNoMetadata(chatItem.paymentNotification!!) + } + } + + private fun ContentValues.addPaymentTombstoneNoAmount() { + put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE) + } + + private fun ContentValues.addPaymentTombstoneNoMetadata(paymentNotification: PaymentNotification) { + put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE) + val amount = tryParseCryptoValue(paymentNotification.amountMob) + val fee = tryParseCryptoValue(paymentNotification.feeMob) + put( + MessageTable.MESSAGE_EXTRAS, + MessageExtras( + paymentTombstone = PaymentTombstone( + note = paymentNotification.note, + amount = amount, + fee = fee + ) + ).encode() + ) + } + + private fun String?.tryParseMoney(): Money? { + if (this.isNullOrEmpty()) { + return null + } + + val amountCryptoValue = tryParseCryptoValue(this) + return if (amountCryptoValue != null) { + CryptoValueUtil.cryptoValueToMoney(amountCryptoValue) + } else { + null + } + } + + private fun tryParseCryptoValue(bigIntegerString: String?): CryptoValue? { + if (bigIntegerString == null) { + return null + } + val amount = try { + BigInteger(bigIntegerString).toString() + } catch (e: NumberFormatException) { + return null + } + return CryptoValue(mobileCoinValue = CryptoValue.MobileCoinValue(picoMobileCoin = amount)) + } + private fun ContentValues.addQuote(quote: Quote) { this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID) this.put(MessageTable.QUOTE_AUTHOR, backupState.backupToLocalRecipientId[quote.authorId]!!.serialize()) @@ -561,6 +716,15 @@ class ChatItemImportInserter( this.put(MessageTable.QUOTE_MISSING, (quote.targetSentTimestamp == null).toInt()) } + private fun PaymentNotification.TransactionDetails.Transaction.Status?.toLocalStatus(): State { + return when (this) { + PaymentNotification.TransactionDetails.Transaction.Status.INITIAL -> State.INITIAL + PaymentNotification.TransactionDetails.Transaction.Status.SUBMITTED -> State.SUBMITTED + PaymentNotification.TransactionDetails.Transaction.Status.SUCCESSFUL -> State.SUCCESSFUL + else -> State.INITIAL + } + } + private fun Quote.Type.toLocalQuoteType(): Int { return when (this) { Quote.Type.UNKNOWN -> QuoteModel.Type.NORMAL.code diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 84120925a7..59d2dc6c28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -110,6 +110,7 @@ import org.thoughtcrime.securesms.database.MediaTable; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.Quote; +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.events.PartProgressEvent; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy; @@ -261,6 +262,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener(); private final DoubleTapEditTouchListener doubleTapEditTouchListener = new DoubleTapEditTouchListener(); private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback(); + private final PaymentTombstoneClickListener paymentTombstoneClickListener = new PaymentTombstoneClickListener(); private final Context context; @@ -1038,7 +1040,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyText.setText(italics); bodyText.setVisibility(View.VISIBLE); bodyText.setOverflowText(null); - } else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord) || isGiftMessage(messageRecord) || messageRecord.isPaymentNotification()) { + } else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord) || isGiftMessage(messageRecord) || messageRecord.isPaymentNotification() || messageRecord.isPaymentTombstone()) { bodyText.setText(null); bodyText.setOverflowText(null); bodyText.setVisibility(View.GONE); @@ -1396,8 +1398,29 @@ public final class ConversationItem extends RelativeLayout implements BindableCo MmsMessageRecord mediaMmsMessageRecord = (MmsMessageRecord) messageRecord; paymentViewStub.setVisibility(View.VISIBLE); + paymentViewStub.get().setOnTombstoneClickListener(paymentTombstoneClickListener); paymentViewStub.get().bindPayment(conversationRecipient.get(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer); + footer.setVisibility(VISIBLE); + } else if (messageRecord.isPaymentTombstone()) { + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(GONE); + if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE); + if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE); + + MmsMessageRecord mediaMmsMessageRecord = (MmsMessageRecord) messageRecord; + + paymentViewStub.setVisibility(View.VISIBLE); + paymentViewStub.get().setOnTombstoneClickListener(paymentTombstoneClickListener); + MessageExtras messageExtras = mediaMmsMessageRecord.getMessageExtras(); + + paymentViewStub.get().bindPaymentTombstone(mediaMmsMessageRecord.isOutgoing(), conversationRecipient.get(), messageExtras == null ? null : messageExtras.paymentTombstone, colorizer); + footer.setVisibility(VISIBLE); } else { if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE); @@ -2352,6 +2375,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return null; } + private class PaymentTombstoneClickListener implements View.OnClickListener { + @Override + public void onClick(View v) { + if (eventListener != null) { + eventListener.onPaymentTombstoneClicked(); + } else { + passthroughClickListener.onClick(v); + } + } + } private class SharedContactEventListener implements SharedContactView.EventListener { @Override public void onAddToContactsClicked(@NonNull Contact contact) { 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 7ae2eb2fce..ce2f24e7ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -135,7 +135,7 @@ public final class MenuState { hasGift = true; } - if (messageRecord.isPaymentNotification()) { + if (messageRecord.isPaymentNotification() || messageRecord.isPaymentTombstone()) { hasPayment = true; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/payment/PaymentMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/payment/PaymentMessageView.kt index bee6c5ebef..131b0f163a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/payment/PaymentMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/payment/PaymentMessageView.kt @@ -14,11 +14,14 @@ import org.signal.core.util.dp import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone import org.thoughtcrime.securesms.databinding.PaymentMessageViewBinding +import org.thoughtcrime.securesms.payments.CryptoValueUtil import org.thoughtcrime.securesms.payments.Direction import org.thoughtcrime.securesms.payments.Payment import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.visible +import org.whispersystems.signalservice.api.payments.Money /** * Showing payment information in conversation. @@ -30,11 +33,25 @@ class PaymentMessageView @JvmOverloads constructor( private val binding: PaymentMessageViewBinding + private var onTombstoneClickListener: OnClickListener? = null + init { binding = PaymentMessageViewBinding.inflate(LayoutInflater.from(context), this, true) } - fun bindPayment(recipient: Recipient, payment: Payment, colorizer: Colorizer) { + fun bindPayment(recipient: Recipient, payment: Payment?, colorizer: Colorizer) { + if (payment == null) { + binding.paymentTombstone.visible = true + binding.paymentAmount.visible = false + binding.paymentInprogress.visible = false + binding.paymentAmountLayout.setOnClickListener { + onTombstoneClickListener?.onClick(it) + } + return + } + + binding.paymentAmountLayout.setOnClickListener(null) + binding.paymentTombstone.visible = false val outgoing = payment.direction == Direction.SENT binding.paymentDirection.apply { @@ -69,6 +86,51 @@ class PaymentMessageView @JvmOverloads constructor( ViewCompat.setBackgroundTintList(binding.paymentAmountLayout, ColorStateList.valueOf(quoteViewColorTheme.getBackgroundColor(context))) } + fun bindPaymentTombstone(outgoing: Boolean, recipient: Recipient, paymentTombstone: PaymentTombstone?, colorizer: Colorizer) { + val amount: Money? = if (paymentTombstone?.amount != null) { + CryptoValueUtil.cryptoValueToMoney(paymentTombstone.amount) + } else { + null + } + + binding.paymentDirection.apply { + if (outgoing) { + text = context.getString(R.string.PaymentMessageView_you_sent_s, recipient.getShortDisplayName(context)) + setTextColor(colorizer.getOutgoingFooterTextColor(context)) + } else { + text = context.getString(R.string.PaymentMessageView_s_sent_you, recipient.getShortDisplayName(context)) + setTextColor(colorizer.getIncomingFooterTextColor(context, recipient.hasWallpaper)) + } + } + if (amount == null) { + binding.paymentTombstone.visible = true + binding.paymentAmount.visible = false + binding.paymentInprogress.visible = false + binding.paymentAmountLayout.setOnClickListener { + onTombstoneClickListener?.onClick(it) + } + return + } + val note = paymentTombstone?.note ?: "" + binding.paymentAmountLayout.setOnClickListener(null) + binding.paymentTombstone.visible = false + + binding.paymentNote.apply { + text = note + visible = note.isNotEmpty() + setTextColor(if (outgoing) colorizer.getOutgoingBodyTextColor(context) else colorizer.getIncomingBodyTextColor(context, recipient.hasWallpaper)) + } + + val quoteViewColorTheme = QuoteViewColorTheme.resolveTheme(outgoing, false, recipient.hasWallpaper) + + binding.paymentAmount.visible = true + binding.paymentInprogress.visible = false + binding.paymentAmount.setTextColor(quoteViewColorTheme.getForegroundColor(context)) + binding.paymentAmount.setMoney(amount, 0L, currencyTypefaceSpan) + + ViewCompat.setBackgroundTintList(binding.paymentAmountLayout, ColorStateList.valueOf(quoteViewColorTheme.getBackgroundColor(context))) + } + private fun getInProgressDrawable(@ColorInt color: Int): IndeterminateDrawable { val spec = CircularProgressIndicatorSpec(context, null).apply { indicatorInset = 0 @@ -82,6 +144,10 @@ class PaymentMessageView @JvmOverloads constructor( return drawable } + fun setOnTombstoneClickListener(listener: OnClickListener?) { + this.onTombstoneClickListener = listener + } + companion object { private val currencyTypefaceSpan = TypefaceSpan("sans-serif-light") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index ec3a10e636..168ca20b4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -2367,12 +2367,26 @@ class ConversationFragment : private fun handleViewPaymentDetails(conversationMessage: ConversationMessage) { val record: MmsMessageRecord = conversationMessage.messageRecord as? MmsMessageRecord ?: return - val payment = record.payment ?: return + val payment = record.payment + if (payment == null || record.isPaymentTombstone) { + showPaymentTombstoneLearnMoreDialog() + return + } if (record.isPaymentNotification) { startActivity(PaymentsActivity.navigateToPaymentDetails(requireContext(), payment.uuid)) } } + private fun showPaymentTombstoneLearnMoreDialog() { + val dialogBuilder = MaterialAlertDialogBuilder(requireContext()) + dialogBuilder + .setTitle(R.string.PaymentTombstoneLearnMoreDialog_title) + .setMessage(R.string.PaymentTombstoneLearnMoreDialog_message) + .setPositiveButton(android.R.string.ok, null) + + dialogBuilder.show() + } + private fun handleDisplayDetails(conversationMessage: ConversationMessage) { val recipientSnapshot = viewModel.recipientSnapshot ?: return MessageDetailsFragment.create(conversationMessage.messageRecord, recipientSnapshot.id).show(childFragmentManager, null) @@ -2801,6 +2815,10 @@ class ConversationFragment : DoubleTapEditEducationSheet(conversationMessage).show(childFragmentManager, DoubleTapEditEducationSheet.KEY) } + override fun onPaymentTombstoneClicked() { + this@ConversationFragment.showPaymentTombstoneLearnMoreDialog() + } + override fun onMessageWithErrorClicked(messageRecord: MessageRecord) { val recipientId = viewModel.recipientSnapshot?.id ?: return if (messageRecord.isIdentityMismatchFailure) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java index c79481a215..a6cefcb948 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java @@ -117,6 +117,7 @@ public interface MessageTypes { long SPECIAL_TYPE_REPORTED_SPAM = 0x500000000L; long SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED = 0x600000000L; long SPECIAL_TYPE_PAYMENTS_ACTIVATED = 0x800000000L; + long SPECIAL_TYPE_PAYMENTS_TOMBSTONE = 0x900000000L; long IGNORABLE_TYPESMASK_WHEN_COUNTING = END_SESSION_BIT | KEY_EXCHANGE_IDENTITY_UPDATE_BIT | KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; @@ -132,6 +133,10 @@ public interface MessageTypes { return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_PAYMENTS_NOTIFICATION; } + static boolean isPaymentTombstone(long type) { + return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_PAYMENTS_TOMBSTONE; + } + static boolean isPaymentsRequestToActivate(long type) { return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PaymentTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/PaymentTable.java index b09e0ef4b3..2e4cb1ed89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/PaymentTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PaymentTable.java @@ -182,6 +182,28 @@ public final class PaymentTable extends DatabaseTable implements RecipientIdData } } + @WorkerThread + public UUID restoreFromBackup(@NonNull RecipientId recipientId, + long timestamp, + long blockIndex, + @NonNull String note, + @NonNull Direction direction, + @NonNull State state, + @NonNull Money amount, + @NonNull Money fee, + @Nullable byte[] transaction, + @Nullable byte[] receipt, + @Nullable PaymentMetaData metaData, + boolean seen) { + UUID uuid = UUID.randomUUID(); + try { + create(uuid, recipientId, null, timestamp, blockIndex, note, direction, state, amount, fee, transaction, receipt, metaData, seen); + } catch (SerializationException | PublicKeyConflictException e) { + return null; + } + return uuid; + } + @WorkerThread private void create(@NonNull UUID uuid, @Nullable RecipientId recipientId, @@ -439,7 +461,7 @@ public final class PaymentTable extends DatabaseTable implements RecipientIdData if (payment != null && record instanceof MmsMessageRecord) { return ((MmsMessageRecord) record).withPayment(payment); } else { - throw new AssertionError("Payment not found for message"); + Log.w(TAG, "Payment not found for message"); } } return record; 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 5ffa864c02..c6af632efe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java @@ -61,7 +61,7 @@ public final class ThreadBodyUtil { return format(EmojiStrings.GIFT, getGiftSummary(context, record), null); } else if (MessageRecordUtil.isStoryReaction(record)) { return new ThreadBody(getStoryReactionSummary(context, record)); - } else if (record.isPaymentNotification()) { + } else if (record.isPaymentNotification() || record.isPaymentTombstone()) { return format(EmojiStrings.CARD, context.getString(R.string.ThreadRecord_payment), null); } else if (record.isPaymentsRequestToActivate()) { return format(EmojiStrings.CARD, getPaymentActivationRequestSummary(context, record), null); 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 58d2acbcd0..54c58da417 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 @@ -229,6 +229,10 @@ public abstract class DisplayRecord { return MessageTypes.isPaymentsNotification(type); } + public boolean isPaymentTombstone() { + return MessageTypes.isPaymentTombstone(type); + } + public boolean isPaymentsRequestToActivate() { return MessageTypes.isPaymentsRequestToActivate(type); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index ff41ee8e08..d331bcb6e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -25,16 +25,20 @@ import org.thoughtcrime.securesms.database.MessageTypes; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; +import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue; import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge; import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.payments.CryptoValueUtil; import org.thoughtcrime.securesms.payments.Payment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.payments.FormatterOptions; +import org.whispersystems.signalservice.api.payments.Money; +import java.io.IOException; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -219,6 +223,18 @@ public class MmsMessageRecord extends MessageRecord { return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported)); } else if (isPaymentNotification() && payment != null) { return new SpannableString(context.getString(R.string.MessageRecord__payment_s, payment.getAmount().toString(FormatterOptions.defaults()))); + } else if (isPaymentTombstone() || isPaymentNotification()) { + MessageExtras extras = getMessageExtras(); + + Money amount = null; + if (extras != null && extras.paymentTombstone != null && extras.paymentTombstone.amount != null) { + amount = CryptoValueUtil.cryptoValueToMoney(extras.paymentTombstone.amount); + } + if (amount == null) { + return new SpannableString(context.getString(R.string.MessageRecord__payment_tombstone)); + } else { + return new SpannableString(context.getString(R.string.MessageRecord__payment_s, amount.toString(FormatterOptions.defaults()))); + } } return super.getDisplayBody(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt index d5462ed7dc..45b41ba0c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt @@ -232,7 +232,7 @@ class MessageNotification(threadRecipient: Recipient, record: MessageRecord) : N ThreadBodyUtil.getFormattedBodyForNotification(context, record, null) } else if (record.isStoryReaction()) { ThreadBodyUtil.getFormattedBodyForNotification(context, record, null) - } else if (record.isPaymentNotification) { + } else if (record.isPaymentNotification || record.isPaymentTombstone) { ThreadBodyUtil.getFormattedBodyForNotification(context, record, null) } else { getBodyWithMentionsAndStyles(context, record) @@ -342,7 +342,7 @@ class ReactionNotification(threadRecipient: Recipient, record: MessageRecord, va context.getString(R.string.MessageNotifier_reacted_s_to_your_sticker, EMOJI_REPLACEMENT_STRING) } else if (record.isMms && record.isViewOnce) { context.getString(R.string.MessageNotifier_reacted_s_to_your_view_once_media, EMOJI_REPLACEMENT_STRING) - } else if (record.isPaymentNotification) { + } else if (record.isPaymentNotification || record.isPaymentTombstone) { context.getString(R.string.MessageNotifier_reacted_s_to_your_payment, EMOJI_REPLACEMENT_STRING) } else if (!bodyIsEmpty) { context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, body) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt index 611f6f00bd..27f083c353 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt @@ -85,6 +85,7 @@ object MessageConstraintsUtil { !message.isRemoteDelete && !message.hasGiftBadge() && !message.isPaymentNotification && + !message.isPaymentTombstone && (currentTime - message.dateSent < SEND_THRESHOLD || message.toRecipient.isSelf) } 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 ae7d6564ae..858252adf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt @@ -139,7 +139,8 @@ fun MessageRecord.isTextOnly(context: Context): Boolean { !hasSticker() && !isCaptionlessMms(context) && !hasGiftBadge() && - !isPaymentNotification() + !isPaymentNotification && + !isPaymentTombstone ) } diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index be715fb09a..0be6504e07 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -499,6 +499,7 @@ message MessageExtras { GV2UpdateDescription gv2UpdateDescription = 1; signalservice.GroupContext gv1Context = 2; ProfileChangeDetails profileChangeDetails = 3; + PaymentTombstone paymentTombstone = 4; } } @@ -506,3 +507,9 @@ message GV2UpdateDescription { optional DecryptedGroupV2Context gv2ChangeDescription = 1; backup.GroupChangeChatUpdate groupChangeUpdate = 2; } + +message PaymentTombstone { + optional string note = 1; + CryptoValue amount = 2; + CryptoValue fee = 3; +} diff --git a/app/src/main/res/layout/payment_message_view.xml b/app/src/main/res/layout/payment_message_view.xml index ee3c898167..3c603fff76 100644 --- a/app/src/main/res/layout/payment_message_view.xml +++ b/app/src/main/res/layout/payment_message_view.xml @@ -42,6 +42,43 @@ android:layout_height="20dp" android:layout_gravity="center" /> + + + + + + + + + + + + + + You activated Payments %s can now accept Payments + + Payment details are not available + + Tap for more + + Payment details not available + + This details of this payment are not available because your messages were restored from a source that did not contain this payment history. This does not affect your wallet balance or the status of past payments. @@ -1701,6 +1709,8 @@ You can no longer send SMS messages in Signal. Invite %1$s to Signal to keep the conversation here. Payment: %1$s + + Payment Reported as spam