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 @@ -58,6 +58,7 @@ val Throwable.chatResult: String
is StartChatError.PictureBlobNotAccepted, is EditChatError.PictureBlobNotAccepted -> "PictureBlobNotAccepted"
is StartChatError.InvalidRules -> "InvalidRules"
is StartChatError.RulesNotSatisfied, is JoinChatError.RulesNotSatisfied -> "RulesNotSatisfied"
is JoinChatError.Banned -> "Banned"
is StartChatError.Unrecognized, is EditChatError.Unrecognized, is JoinChatError.Unrecognized,
is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized -> "Unrecognized"
is ValidationException -> "Validation"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class ConversionsTest {
fun `a chat error reports the proto result name, not its message`() {
assertEquals("RulesNotSatisfied", JoinChatError.RulesNotSatisfied().chatResult)
assertEquals("NotFound", JoinChatError.NotFound().chatResult)
assertEquals("Banned", JoinChatError.Banned().chatResult)
// MuteChatError.Denied's message is "Caller is not a member".
assertEquals("Denied", MuteChatError.Denied().chatResult)
assertEquals("TitleModerated", StartChatError.TitleModerated(mockk(relaxed = true)).chatResult)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,21 @@ sealed interface MessageContentSerialized {
// Base58, like [intentId]. Null for rows stored before this existed, and for messages
// that carried no receiver.
val receiver: String? = null,
// Each receiver's part of a multi-receiver payment. Empty for rows stored before this
// existed, and for single-receiver payments.
val multiReceiver: List<CashPayment> = emptyList(),
// Base58, like [intentId]. Null unless the payment paid a payment-request share.
val paymentRequestId: String? = null,
) : MessageContentSerialized

@Serializable
data class CashPayment(
// Base58, like [Cash.receiver].
val receiver: String,
val nativeAmount: Double,
val tokenQuarks: Long,
)

@Serializable
@SerialName("deleted")
data class Deleted(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,14 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
action = action.name,
tokenQuarks = tokenQuarks,
receiver = receiver?.base58,
multiReceiver = multiReceiver.map {
MessageContentSerialized.CashPayment(
receiver = it.receiver.base58,
nativeAmount = it.nativeAmount,
tokenQuarks = it.tokenQuarks,
)
},
paymentRequestId = paymentRequestId?.base58,
)
is MessageContent.Reply -> MessageContentSerialized.Reply(
repliedMessageId = repliedMessageId,
Expand Down Expand Up @@ -451,6 +459,14 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
?: MessageContent.Cash.Action.SENT,
tokenQuarks = tokenQuarks,
receiver = receiver?.decodeBase58()?.toList(),
multiReceiver = multiReceiver.map {
MessageContent.Cash.Payment(
receiver = it.receiver.decodeBase58().toList(),
nativeAmount = it.nativeAmount,
tokenQuarks = it.tokenQuarks,
)
},
paymentRequestId = paymentRequestId?.decodeBase58()?.toList(),
)
is MessageContentSerialized.Reply -> MessageContent.Reply(
repliedMessageId = repliedMessageId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,27 @@ class ChatEntityMapperTest {
assertNull(restored.tokenQuarks)
}

@Test
fun `cash multi-receiver parts and payment request id survive the round trip`() {
val parts = listOf(
MessageContent.Cash.Payment(listOf<Byte>(1), 1.25, 4L),
MessageContent.Cash.Payment(listOf<Byte>(2), 1.25, 6L),
)
val requestId = List<Byte>(16) { (it + 1).toByte() }
val message = cashMessage(tokenQuarks = 10L).let { m ->
m.copy(content = m.content.map {
(it as MessageContent.Cash).copy(multiReceiver = parts, paymentRequestId = requestId)
})
}

val restored = mapper.toMessage(mapper.toEntity(CHAT_HEX, message))
.content.single() as MessageContent.Cash

assertEquals(parts, restored.multiReceiver)
assertEquals(requestId, restored.paymentRequestId)
assertNull(restored.receiver)
}

@Test
fun `cash receiver survives the round trip, and its absence stays null`() {
val receiver = listOf<Byte>(9, 8, 7)
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,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.20.0"
flipcash2-client-protocol = "0.21.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
Expand Up @@ -12,6 +12,8 @@ import com.flipcash.services.models.chat.KeyEnvelope
import com.flipcash.services.models.chat.Lobby
import com.flipcash.services.models.chat.LobbyPage
import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.RestrictedUsersPage
import com.flipcash.services.models.chat.SilenceState
import com.flipcash.services.models.chat.MentionSuggestion
import com.flipcash.services.models.chat.RosterPage
import com.flipcash.services.models.chat.StartChatParameters
Expand Down Expand Up @@ -210,6 +212,46 @@ class ChatController @Inject constructor(
return repository.denyLobbyMember(owner, chatId, userId)
}

/** Silences [userId] in [chatId] for the duration in [silence]. */
suspend fun silenceUser(chatId: ChatId, userId: ID, silence: SilenceState): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.silenceUser(owner, chatId, userId, silence)
}

/** Lifts [userId]'s silence in [chatId]. */
suspend fun unsilenceUser(chatId: ChatId, userId: ID): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.unsilenceUser(owner, chatId, userId)
}

/** Bans [userId] from [chatId]. */
suspend fun banUser(chatId: ChatId, userId: ID): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.banUser(owner, chatId, userId)
}

/** Lifts [userId]'s ban from [chatId]. */
suspend fun unbanUser(chatId: ChatId, userId: ID): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.unbanUser(owner, chatId, userId)
}

/** One page of the users silenced or banned in [chatId]. */
suspend fun getRestrictedUsers(chatId: ChatId, queryOptions: QueryOptions = QueryOptions()): Result<RestrictedUsersPage> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.getRestrictedUsers(owner, chatId, queryOptions)
}

/** Stores the caller's own key envelope for [chatId]. */
suspend fun setKeyEnvelope(chatId: ChatId, keyEnvelope: KeyEnvelope): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.flipcash.services.controllers

import com.codeinc.flipcash.gen.paymentrequest.v1.Model
import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.paymentrequest.PaymentRequestDetails
import com.flipcash.services.models.paymentrequest.PaymentRequestPage
import com.flipcash.services.models.paymentrequest.PaymentRequestSection
import com.flipcash.services.repository.PaymentRequestRepository
import com.flipcash.services.user.UserManager
import com.getcode.opencode.model.core.ID
import javax.inject.Inject

class PaymentRequestController @Inject constructor(
private val repository: PaymentRequestRepository,
private val userManager: UserManager,
) {
suspend fun createPaymentRequest(groupChat: Model.GroupChatPaymentRequest, idempotencyKey: Model.IdempotencyKey): Result<Model.PaymentRequestMetadata> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.createPaymentRequest(owner, groupChat, idempotencyKey)
}

suspend fun getPaymentRequest(paymentRequestId: Model.PaymentRequestId): Result<PaymentRequestDetails> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.getPaymentRequest(owner, paymentRequestId)
}

suspend fun getChatPaymentRequests(chatId: ChatId, section: PaymentRequestSection, queryOptions: QueryOptions = QueryOptions()): Result<PaymentRequestPage> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.getChatPaymentRequests(owner, chatId, section, queryOptions)
}

suspend fun closePaymentRequest(paymentRequestId: Model.PaymentRequestId): Result<Model.PaymentRequestMetadata> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.closePaymentRequest(owner, paymentRequestId)
}

suspend fun markPaid(paymentRequestId: Model.PaymentRequestId, userId: ID): Result<Model.PaymentRequestMetadata> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.markPaid(owner, paymentRequestId, userId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ import com.flipcash.services.internal.network.services.ChatMessagingService
import com.flipcash.services.internal.network.services.EmailVerificationService
import com.flipcash.services.internal.network.services.ContactListService
import com.flipcash.services.internal.network.services.ModerationService
import com.flipcash.services.internal.network.services.PaymentRequestService
import com.flipcash.services.internal.network.services.PollService
import com.flipcash.services.internal.repositories.InternalPaymentRequestRepository
import com.flipcash.services.internal.repositories.InternalPollRepository
import com.flipcash.services.repository.PaymentRequestRepository
import com.flipcash.services.repository.PollRepository
import com.flipcash.services.internal.network.services.PhoneVerificationService
import com.flipcash.services.internal.network.services.ProfileService
Expand Down Expand Up @@ -273,6 +276,11 @@ internal object FlipcashModule {
service: PollService,
): PollRepository = InternalPollRepository(service)

@Provides
internal fun providesPaymentRequestRepository(
service: PaymentRequestService,
): PaymentRequestRepository = InternalPaymentRequestRepository(service)

@Provides
internal fun providesModerationRepository(
service: ModerationService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.services.internal.network.extensions.asChatId
import com.flipcash.services.internal.network.extensions.asProtoChatType
import com.flipcash.services.internal.network.extensions.asProtoIdempotencyKey
import com.flipcash.services.internal.network.extensions.asProtoMuteState
import com.flipcash.services.internal.network.extensions.asProtoSilenceState
import com.flipcash.services.internal.network.extensions.asProtoRules
import com.flipcash.services.internal.network.extensions.asQueryOptions
import com.flipcash.services.internal.network.extensions.asProtoKeyEnvelope
Expand All @@ -23,6 +24,7 @@ import com.flipcash.services.models.chat.EditChatParameters
import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.chat.KeyEnvelope
import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.SilenceState
import com.flipcash.services.models.chat.StartChatParameters
import com.flipcash.services.models.chat.ViewMode
import com.getcode.utils.toByteString
Expand Down Expand Up @@ -461,6 +463,98 @@ internal class ChatApi @Inject constructor(
}
}

suspend fun silenceUser(
owner: KeyPair,
chatId: ChatId,
userId: ID,
silence: SilenceState,
): RpcChatService.SilenceUserResponse {
val request = RpcChatService.SilenceUserRequest.newBuilder()
.setChatId(chatId.asChatId())
.setUserId(userId.asUserId())
.setSilence(silence.asProtoSilenceState())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

suspend fun unsilenceUser(
owner: KeyPair,
chatId: ChatId,
userId: ID,
): RpcChatService.UnsilenceUserResponse {
val request = RpcChatService.UnsilenceUserRequest.newBuilder()
.setChatId(chatId.asChatId())
.setUserId(userId.asUserId())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

suspend fun banUser(
owner: KeyPair,
chatId: ChatId,
userId: ID,
): RpcChatService.BanUserResponse {
val request = RpcChatService.BanUserRequest.newBuilder()
.setChatId(chatId.asChatId())
.setUserId(userId.asUserId())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

suspend fun unbanUser(
owner: KeyPair,
chatId: ChatId,
userId: ID,
): RpcChatService.UnbanUserResponse {
val request = RpcChatService.UnbanUserRequest.newBuilder()
.setChatId(chatId.asChatId())
.setUserId(userId.asUserId())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

suspend fun getRestrictedUsers(
owner: KeyPair,
chatId: ChatId,
queryOptions: QueryOptions = QueryOptions(),
): RpcChatService.GetRestrictedUsersResponse {
val request = RpcChatService.GetRestrictedUsersRequest.newBuilder()
.setChatId(chatId.asChatId())
.setQueryOptions(queryOptions.asQueryOptions())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

suspend fun setKeyEnvelope(
owner: KeyPair,
chatId: ChatId,
Expand Down
Loading
Loading