Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.app.core.feed

import com.flipcash.services.models.BoostedReaction
import com.flipcash.services.models.RequestedPayment
import com.flipcash.services.models.ReturnedPayment
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.LocalFiat
Expand Down Expand Up @@ -115,6 +116,8 @@ sealed interface MessageMetadata {
val boostedReaction: BoostedReaction? = null,
// Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set.
val requestedPayment: RequestedPayment? = null,
// Defaulted like [boostedReaction]; the same oneof on the wire.
val returnedPayment: ReturnedPayment? = null,
) : MessageMetadata

@Serializable
Expand All @@ -131,6 +134,8 @@ sealed interface MessageMetadata {
val boostedReaction: BoostedReaction? = null,
// Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set.
val requestedPayment: RequestedPayment? = null,
// Defaulted like [boostedReaction]; the same oneof on the wire.
val returnedPayment: ReturnedPayment? = null,
) : MessageMetadata

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,17 @@ sealed interface MessageContentSerialized {
val multiReceiver: List<CashPayment> = emptyList(),
// Base58, like [intentId]. Null unless the payment paid a payment-request share.
val paymentRequestId: String? = null,
// Null for rows stored before this existed, and for payments with no return involvement.
val paymentReturn: CashPaymentReturn? = null,
) : MessageContentSerialized

@Serializable
data class CashPaymentReturn(
val offered: Boolean = false,
// The message id of the payment this one returns; null if it is not a return.
val returnOfMessageId: Long? = null,
)

@Serializable
data class CashPayment(
// Base58, like [Cash.receiver].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,12 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
)
},
paymentRequestId = paymentRequestId?.base58,
paymentReturn = paymentReturn?.let {
MessageContentSerialized.CashPaymentReturn(
offered = it.offer != null,
returnOfMessageId = it.returnOf?.messageId,
)
},
)
is MessageContent.Reply -> MessageContentSerialized.Reply(
repliedMessageId = repliedMessageId,
Expand Down Expand Up @@ -516,6 +522,12 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
)
},
paymentRequestId = paymentRequestId?.decodeBase58()?.toList(),
paymentReturn = paymentReturn?.let {
MessageContent.Cash.PaymentReturn(
offer = if (it.offered) MessageContent.Cash.PaymentReturn.Offer else null,
returnOf = it.returnOfMessageId?.let(MessageContent.Cash.PaymentReturn::ReturnOf),
)
},
)
is MessageContentSerialized.Reply -> MessageContent.Reply(
repliedMessageId = repliedMessageId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ class MetadataMapper @Inject constructor(): Mapper<NotificationMetadata?, Messag
override fun map(from: NotificationMetadata?): MessageMetadata? {
from ?: return null
return when (from) {
is NotificationMetadata.DirectlySentCrypto -> MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment)
is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment)
is NotificationMetadata.DirectlySentCrypto -> MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment, from.returnedPayment)
is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment, from.returnedPayment)
is NotificationMetadata.IndirectlySentCrypto -> MessageMetadata.IndirectlySentCrypto(from.creator, from.canCancel)
NotificationMetadata.Unknown -> MessageMetadata.Unknown
is NotificationMetadata.WithdrewCrypto -> MessageMetadata.WithdrewCrypto(from.swapMetadata?.toMessage())
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ protovalidate-kt = "0.1.3"
# 0.3.0 is the first release of either package to ship R8 keep rules for its generated
# messages, which is what lets proguard-rules.pro drop its own.
ocp-client-protocol = "0.9.0"
flipcash2-client-protocol = "0.24.0"
flipcash2-client-protocol = "0.25.0"

# The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's
# `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.services.controllers

import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.chat.CashMessageReturnStatus
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ClientMessageId
Expand Down Expand Up @@ -584,6 +585,13 @@ private class FakeChatMessagingRepository : ChatMessagingRepository {
return getMessageResult
}

var getCashMessageReturnStatusResult: Result<CashMessageReturnStatus> = Result.failure(RuntimeException("not configured"))

override suspend fun getCashMessageReturnStatus(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long): Result<CashMessageReturnStatus> {
lastChatId = chatId; lastMessageId = messageId
return getCashMessageReturnStatusResult
}

override suspend fun getMessages(owner: Ed25519.KeyPair, chatId: ChatId, queryOptions: QueryOptions, viewMode: ViewMode): Result<List<ChatMessage>> {
lastChatId = chatId; lastQueryOptions = queryOptions
return getMessagesResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,4 +393,39 @@ class ActivityFeedMessageMapperTest {
assertNotNull(amount)
assertEquals(12_000_000L, amount.underlyingTokenAmount.quarks)
}

// --- Returned payment ---

private fun returnedPayment() = Model.ReturnedPayment.newBuilder()
.setChatId(com.codeinc.flipcash.gen.common.v1.Common.ChatId.newBuilder().setValue(ByteString.copyFrom(ByteArray(32) { 7 })))
.setMessageId(com.codeinc.flipcash.gen.messaging.v1.Model.MessageId.newBuilder().setValue(99L))
.build()

@Test
fun `returned payment on a sent notification is mapped`() {
val proto = baseNotification {
directlySentCrypto = Model.DirectlySentCryptoNotificationMetadata.newBuilder()
.setReturnedPayment(returnedPayment())
.build()
}

val meta = assertIs<NotificationMetadata.DirectlySentCrypto>(mapper.map(proto).metadata)

assertEquals(99L, meta.returnedPayment?.messageId)
assertNull(meta.boostedReaction)
assertNull(meta.requestedPayment)
}

@Test
fun `returned payment on a received notification is mapped`() {
val proto = baseNotification {
receivedCrypto = Model.ReceivedCryptoNotificationMetadata.newBuilder()
.setReturnedPayment(returnedPayment())
.build()
}

val meta = assertIs<NotificationMetadata.ReceivedCrypto>(mapper.map(proto).metadata)

assertEquals(99L, meta.returnedPayment?.messageId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,23 @@ class DmPaymentMetadataTest {
}

private fun bytes(size: Int, value: (Int) -> Int) = ByteArray(size) { value(it).toByte() }

@Test
fun `return payment metadata carries the chat and the returned message id`() {
val bytes = buildReturnPaymentMetadata(chatId = chatId, messageId = 42L)

val decoded = FlipcashIntentModel.AppMetadata.parseFrom(bytes!!)

assertEquals(chatId.bytes.toByteString(), decoded.chat.chatId.value)
assertEquals(42L, decoded.chat.returnPayment.messageId.value)
assertEquals(
FlipcashIntentModel.ChatMetadata.TypeCase.RETURN_PAYMENT,
decoded.chat.typeCase,
)
}

@Test
fun `return payment metadata is null without a chat id`() {
assertNull(buildReturnPaymentMetadata(chatId = null, messageId = 42L))
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.services.controllers

import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.chat.CashMessageReturnStatus
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ClientMessageId
Expand Down Expand Up @@ -36,6 +37,18 @@ class ChatMessagingController @Inject constructor(
return repository.getMessage(owner, chatId, messageId, viewMode)
}

/**
* Whether [messageId]'s payment, in the DM [chatId], can still be returned. A
* [CashMessageReturnStatus.Status.RETURNED] answer is terminal, so callers can stop asking.
*/
suspend fun getCashMessageReturnStatus(
chatId: ChatId,
messageId: Long,
): Result<CashMessageReturnStatus> {
val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) }
return repository.getCashMessageReturnStatus(owner, chatId, messageId)
}

suspend fun getMessages(
chatId: ChatId,
queryOptions: QueryOptions = QueryOptions(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.flipcash.services.internal.network.extensions.toPublicKey
import com.flipcash.services.models.ActivityFeedNotification
import com.flipcash.services.models.BoostedReaction
import com.flipcash.services.models.RequestedPayment
import com.flipcash.services.models.ReturnedPayment
import com.flipcash.services.models.NotificationMetadata
import com.flipcash.services.models.chat.Emoji
import com.flipcash.services.internal.network.extensions.toChatId
Expand Down Expand Up @@ -57,6 +58,7 @@ internal class ActivityFeedMessageMapper @Inject constructor(
) meta.userId.value.toByteArray().toList() else null,
boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null,
requestedPayment = if (meta.hasRequestedPayment()) meta.requestedPayment.toDomain() else null,
returnedPayment = if (meta.hasReturnedPayment()) meta.returnedPayment.toDomain() else null,
)
}
Model.Notification.AdditionalMetadataCase.RECEIVED_CRYPTO -> {
Expand All @@ -70,6 +72,7 @@ internal class ActivityFeedMessageMapper @Inject constructor(
) meta.userId.value.toByteArray().toList() else null,
boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null,
requestedPayment = if (meta.hasRequestedPayment()) meta.requestedPayment.toDomain() else null,
returnedPayment = if (meta.hasReturnedPayment()) meta.returnedPayment.toDomain() else null,
)
}
Model.Notification.AdditionalMetadataCase.WITHDREW_CRYPTO -> NotificationMetadata.WithdrewCrypto(
Expand Down Expand Up @@ -180,3 +183,8 @@ private fun Model.BoostedReaction.toDomain(): BoostedReaction = BoostedReaction(
messageId = messageId.value,
emoji = Emoji(emoji.value),
)

private fun Model.ReturnedPayment.toDomain(): ReturnedPayment = ReturnedPayment(
chatId = chatId.toChatId(),
messageId = messageId.value,
)
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ internal class ChatMessagingApi @Inject constructor(
}
}

suspend fun getCashMessageReturnStatus(
owner: KeyPair,
chatId: ChatId,
messageId: Long,
): RpcMessagingService.GetCashMessageReturnStatusResponse {
val request = RpcMessagingService.GetCashMessageReturnStatusRequest.newBuilder()
.setChatId(chatId.asChatId())
.setMessageId(MessagingModel.MessageId.newBuilder().setValue(messageId))
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

return withContext(Dispatchers.IO) {
api.getCashMessageReturnStatus(request)
}
}

suspend fun getMessages(
owner: KeyPair,
chatId: ChatId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent {
emptyList()
},
paymentRequestId = if (cash.hasPaymentRequestId()) cash.paymentRequestId.value.toByteArray().toList() else null,
paymentReturn = if (cash.hasPaymentReturn()) {
MessageContent.Cash.PaymentReturn(
offer = if (cash.paymentReturn.hasOffer()) MessageContent.Cash.PaymentReturn.Offer else null,
returnOf = if (cash.paymentReturn.hasReturnOf()) {
MessageContent.Cash.PaymentReturn.ReturnOf(cash.paymentReturn.returnOf.messageId.value)
} else {
null
},
)
} else {
null
},
)
MessagingModel.Content.TypeCase.REPLY -> MessageContent.Reply(
repliedMessageId = reply.repliedMessageId.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import com.flipcash.services.models.GetReactionSummariesError
import com.flipcash.services.models.GetReactionSummaryError
import com.flipcash.services.models.GetReactorsError
import com.flipcash.services.models.SendMessageError
import com.flipcash.services.models.GetCashMessageReturnStatusError
import com.flipcash.services.models.GetMessageError
import com.flipcash.services.models.chat.CashMessageReturnStatus
import com.flipcash.services.models.paymentrequest.PaymentIntentId
import com.flipcash.services.models.GetMessagesError
import com.flipcash.services.models.NotifyIsTypingError
import com.flipcash.services.models.PagingToken
Expand Down Expand Up @@ -66,6 +69,44 @@ internal class ChatMessagingService @Inject constructor(
)
}

suspend fun getCashMessageReturnStatus(
owner: KeyPair,
chatId: ChatId,
messageId: Long,
): Result<CashMessageReturnStatus> {
return runCatching {
api.getCashMessageReturnStatus(owner, chatId, messageId)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
RpcMessagingService.GetCashMessageReturnStatusResponse.Result.OK -> Result.success(
CashMessageReturnStatus(
status = when (response.status) {
RpcMessagingService.GetCashMessageReturnStatusResponse.Status.AVAILABLE ->
CashMessageReturnStatus.Status.AVAILABLE
RpcMessagingService.GetCashMessageReturnStatusResponse.Status.RETURNED ->
CashMessageReturnStatus.Status.RETURNED
else -> CashMessageReturnStatus.Status.UNKNOWN
},
paymentIntentId = if (response.hasPaymentIntentId()) {
PaymentIntentId(response.paymentIntentId.value.toByteArray())
} else {
null
},
)
)
RpcMessagingService.GetCashMessageReturnStatusResponse.Result.DENIED -> Result.failure(GetCashMessageReturnStatusError.Denied())
RpcMessagingService.GetCashMessageReturnStatusResponse.Result.NOT_FOUND -> Result.failure(GetCashMessageReturnStatusError.NotFound())
RpcMessagingService.GetCashMessageReturnStatusResponse.Result.UNRECOGNIZED -> Result.failure(GetCashMessageReturnStatusError.Unrecognized())
else -> Result.failure(GetCashMessageReturnStatusError.Other())
}
},
onFailure = { cause ->
Result.failure(cause.toValidationOrElse { GetCashMessageReturnStatusError.Other(cause = it) })
}
)
}

suspend fun getMessages(
owner: KeyPair,
chatId: ChatId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.flipcash.services.internal.repositories
import com.flipcash.services.internal.network.extensions.toChatMessage
import com.flipcash.services.internal.network.services.ChatMessagingService
import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.GetCashMessageReturnStatusError
import com.flipcash.services.models.chat.CashMessageReturnStatus
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ClientMessageId
Expand Down Expand Up @@ -34,6 +36,13 @@ internal class InternalChatMessagingRepository(
.onFailure { ErrorUtils.handleError(it) }
.map { it.toChatMessage() }

override suspend fun getCashMessageReturnStatus(
owner: KeyPair,
chatId: ChatId,
messageId: Long,
): Result<CashMessageReturnStatus> = service.getCashMessageReturnStatus(owner, chatId, messageId)
.onFailure { if (it !is GetCashMessageReturnStatusError.NotFound) ErrorUtils.handleError(it) }

override suspend fun getMessages(
owner: KeyPair,
chatId: ChatId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ sealed interface NotificationMetadata {
val boostedReaction: BoostedReaction? = null,
// Mutually exclusive with [boostedReaction] (a oneof on the wire).
val requestedPayment: RequestedPayment? = null,
// Same `context` oneof as [boostedReaction] and [requestedPayment], so at most one is set.
val returnedPayment: ReturnedPayment? = null,
) : NotificationMetadata

/**
Expand All @@ -85,6 +87,8 @@ sealed interface NotificationMetadata {
val boostedReaction: BoostedReaction? = null,
// Mutually exclusive with [boostedReaction] (a oneof on the wire).
val requestedPayment: RequestedPayment? = null,
// Same `context` oneof as [boostedReaction] and [requestedPayment], so at most one is set.
val returnedPayment: ReturnedPayment? = null,
) : NotificationMetadata

/**
Expand Down Expand Up @@ -140,6 +144,18 @@ data class RequestedPayment(
val paymentRequestId: ID,
)

/**
* Set on a sent/received payment notification when the payment returned a payment made in a DM.
* On a directly-sent payment the sender is giving back what the recipient paid them; on a received
* one the payer returned the recipient's payment. Not kept up to date: [messageId] may since have
* been deleted.
*/
@Serializable
data class ReturnedPayment(
val chatId: ChatId,
val messageId: Long,
)

enum class SwapState {
UNKNOWN,
PENDING,
Expand Down
Loading
Loading