diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6776837c1..f753cb8e99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.2" # 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.4.0" -flipcash2-client-protocol = "0.7.0" +flipcash2-client-protocol = "0.8.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, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt index c8eb45f503..630ba36134 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -5,6 +5,8 @@ import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager import javax.inject.Inject @@ -41,6 +43,25 @@ class ChatController @Inject constructor( return repository.getGroupChatFeed(owner, queryOptions) } + /** + * Starts a new chat from [parameters]. + * + * [idempotencyKey] is deliberately a required parameter rather than something this method + * mints itself: for the retry safety to hold, the *caller* must mint it once where the + * intent to start the chat originates (e.g. when the user taps "Create") and pass the same + * instance again on every retry of that attempt. Minting a fresh key here would make every + * retry look like a brand-new chat to the server. + */ + suspend fun startChat( + parameters: StartChatParameters, + idempotencyKey: IdempotencyKey, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.startChat(owner, parameters, idempotencyKey) + } + suspend fun joinChat(chatId: ChatId): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index b38258c615..7a3f8762df 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -6,11 +6,16 @@ import com.codeinc.flipcash.gen.chat.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel 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.asProtoRules import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.StartChatParameters +import com.getcode.utils.toByteString import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow @@ -79,6 +84,45 @@ internal class ChatApi @Inject constructor( } } + /** + * Starts a new chat. [idempotencyKey] must be minted by the caller — once per logical + * attempt — and reused across retries of the same attempt; generating a fresh key per call + * defeats the retry-safety the server offers. + */ + suspend fun startChat( + owner: KeyPair, + parameters: StartChatParameters, + idempotencyKey: IdempotencyKey, + ): RpcChatService.StartChatResponse { + val requestBuilder = RpcChatService.StartChatRequest.newBuilder() + when (parameters) { + is StartChatParameters.Group -> requestBuilder.setGroup( + RpcChatService.StartChatRequest.GroupChatParameters.newBuilder() + .setTitle(parameters.title) + .apply { + parameters.picture?.let { + setPicture( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(it.bytes.toByteString()) + ) + } + } + .apply { parameters.rules?.let { setRules(it.asProtoRules()) } } + ) + } + + val request = requestBuilder + .setIdempotencyKey(idempotencyKey.asProtoIdempotencyKey()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.startChat(request) + } + } + suspend fun joinChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index ff36aadb59..6f1ecb9c41 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -8,8 +8,11 @@ import com.flipcash.services.models.PagingToken import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState @@ -228,4 +231,37 @@ internal fun TypingState.asTypingState(): MessagingModel.IsTypingNotification.St TypingState.TYPING_TIMED_OUT -> MessagingModel.IsTypingNotification.State.TYPING_TIMED_OUT TypingState.UNKNOWN -> MessagingModel.IsTypingNotification.State.UNKNOWN_TYPING_STATE } -} \ No newline at end of file +} +internal fun IdempotencyKey.asProtoIdempotencyKey(): ChatModel.IdempotencyKey { + return ChatModel.IdempotencyKey.newBuilder().setValue(bytes.toByteString()).build() +} + +internal fun ChatRules.asProtoRules(): ChatModel.Rules { + return ChatModel.Rules.newBuilder() + .addAllListener(listener.map { it.asProtoListenerRules() }) + .addAllSpeaker(speaker.map { it.asProtoSpeakerRules() }) + .build() +} + +internal fun ChatRuleRequirement.asProtoListenerRules(): ChatModel.ListenerRules { + val builder = ChatModel.ListenerRules.newBuilder() + return when (this) { + is ChatRuleRequirement.MinimumBalance -> builder.setMinimumBalance(asProtoMinimumBalanceRequirement()).build() + ChatRuleRequirement.Staff -> builder.setStaff(ChatModel.StaffRequirement.getDefaultInstance()).build() + } +} + +internal fun ChatRuleRequirement.asProtoSpeakerRules(): ChatModel.SpeakerRules { + val builder = ChatModel.SpeakerRules.newBuilder() + return when (this) { + is ChatRuleRequirement.MinimumBalance -> builder.setMinimumBalance(asProtoMinimumBalanceRequirement()).build() + ChatRuleRequirement.Staff -> builder.setStaff(ChatModel.StaffRequirement.getDefaultInstance()).build() + } +} + +internal fun ChatRuleRequirement.MinimumBalance.asProtoMinimumBalanceRequirement(): ChatModel.MinimumBalanceRequirement { + return ChatModel.MinimumBalanceRequirement.newBuilder() + .setAmount(amount.asFiatPaymentAmount()) + .addAllMints(mints.map { it.asPublicKey() }) + .build() +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index 25eee5038e..0d98dbd6c8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -3,13 +3,17 @@ package com.flipcash.services.internal.network.services import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.flipcash.services.internal.network.api.ChatApi +import com.flipcash.services.internal.network.extensions.toFlaggedCategory import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetDmChatFeedError import com.flipcash.services.models.GetGroupChatFeedError import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.LeaveChatError import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.StartChatError import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression @@ -109,6 +113,33 @@ internal class ChatService @Inject constructor( ) } + suspend fun startChat( + owner: KeyPair, + parameters: StartChatParameters, + idempotencyKey: IdempotencyKey, + ): Result { + return runCatching { + api.startChat(owner, parameters, idempotencyKey) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.StartChatResponse.Result.OK -> Result.success(response.chat) + RpcChatService.StartChatResponse.Result.DENIED -> Result.failure(StartChatError.Denied()) + RpcChatService.StartChatResponse.Result.TITLE_MODERATED -> + Result.failure(StartChatError.TitleModerated(response.flaggedCategory.toFlaggedCategory())) + RpcChatService.StartChatResponse.Result.PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(StartChatError.PictureBlobNotAccepted()) + RpcChatService.StartChatResponse.Result.INVALID_RULES -> Result.failure(StartChatError.InvalidRules()) + RpcChatService.StartChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(StartChatError.RulesNotSatisfied()) + RpcChatService.StartChatResponse.Result.UNRECOGNIZED -> Result.failure(StartChatError.Unrecognized()) + else -> Result.failure(StartChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { StartChatError.Other(cause = it) }) + } + ) + } + suspend fun leaveChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index abcadefa0d..1b4568d316 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -9,6 +9,8 @@ import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.repository.ChatRepository import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.utils.ErrorUtils @@ -51,6 +53,14 @@ internal class InternalChatRepository( ) } + override suspend fun startChat( + owner: KeyPair, + parameters: StartChatParameters, + idempotencyKey: IdempotencyKey, + ): Result = service.startChat(owner, parameters, idempotencyKey) + .onFailure { ErrorUtils.handleError(it) } + .map { mapper.map(it) } + override suspend fun joinChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index 36a11313d6..4b34262776 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -396,6 +396,22 @@ sealed class LeaveChatError( data class Other(override val cause: Throwable? = null) : LeaveChatError(message = cause?.message, cause = cause), NotifiableError } +sealed class StartChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : StartChatError("Denied") + class TitleModerated(val category: ModerationResult.FlaggedCategory) : StartChatError("Title flagged: $category") + class PictureBlobNotAccepted : StartChatError("Picture blob not accepted") + class InvalidRules : StartChatError("Invalid rules") + // The caller does not meet the rules they are setting on the chat being created (e.g. a + // minimum balance requirement). Recoverable by the user, so deliberately not a + // NotifiableError, matching JoinChatError.RulesNotSatisfied. + class RulesNotSatisfied : StartChatError("Rules not satisfied") + class Unrecognized : StartChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : StartChatError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetMessageError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/IdempotencyKey.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/IdempotencyKey.kt new file mode 100644 index 0000000000..8535e343e4 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/IdempotencyKey.kt @@ -0,0 +1,32 @@ +package com.flipcash.services.models.chat + +import kotlinx.serialization.Serializable +import java.security.SecureRandom + +/** + * A client-minted key that makes a request that creates something safe to retry. + * + * The server derives the created object's identity from the caller and this key, so a retry with + * the same key returns the original result instead of creating a duplicate — even if the retry's + * parameters differ from the first attempt's. + * + * Mint one [random] key where the intent that creates the object originates (e.g. the moment the + * user taps "Create"), and carry that same instance through every retry of that one attempt. + * Generating a fresh key per retry defeats the point: each retry would look like a brand-new + * request to the server. + */ +@Serializable +@JvmInline +value class IdempotencyKey(val bytes: ByteArray) { + + companion object { + private const val SIZE_BYTES = 16 + + /** Mints a new random key. Call once per logical attempt; reuse it across retries. */ + fun random(): IdempotencyKey { + val bytes = ByteArray(SIZE_BYTES) + SecureRandom().nextBytes(bytes) + return IdempotencyKey(bytes) + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/StartChatParameters.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/StartChatParameters.kt new file mode 100644 index 0000000000..0f74750c0a --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/StartChatParameters.kt @@ -0,0 +1,22 @@ +package com.flipcash.services.models.chat + +/** + * Parameters for starting a new chat. The variant selects the kind of chat created. + * + * Only group chats are creatable through StartChat today; DM chats are opened by resolving a + * [ChatId] instead and come into existence on the server the first time a message is sent. + */ +sealed interface StartChatParameters { + /** Parameters for creating a group chat. */ + data class Group( + /** Title for the chat. */ + val title: String, + /** The blob holding the ORIGINAL picture the caller uploaded. Optional. */ + val picture: BlobId? = null, + /** + * Participation requirements for the chat. Optional; if not set, the chat has no + * participation requirements. The caller must satisfy the rules for the chat to start. + */ + val rules: ChatRules? = null, + ) : StartChatParameters +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt index 673268e6bb..ba831278b4 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -5,6 +5,8 @@ import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.StartChatParameters import com.getcode.ed25519.Ed25519.KeyPair interface ChatRepository { @@ -28,6 +30,17 @@ interface ChatRepository { queryOptions: QueryOptions, ): Result + /** + * Starts a new chat from [parameters]. [idempotencyKey] must be minted by the caller once + * per logical attempt and reused across retries of that attempt — a retry with the same key + * returns the originally created chat (result OK) even if [parameters] differ. + */ + suspend fun startChat( + owner: KeyPair, + parameters: StartChatParameters, + idempotencyKey: IdempotencyKey, + ): Result + /** Adds the caller to [chatId]'s roster, returning the chat as the caller now sees it. */ suspend fun joinChat( owner: KeyPair, diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt index 61c7f0f9aa..f990fcc522 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -5,6 +5,8 @@ import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -322,6 +324,7 @@ private class FakeChatRepository : ChatRepository { var getChatResult: Result = Result.failure(RuntimeException("not configured")) var getDmChatFeedResult: Result = Result.failure(RuntimeException("not configured")) var getGroupChatFeedResult: Result = Result.failure(RuntimeException("not configured")) + var startChatResult: Result = Result.failure(RuntimeException("not configured")) var joinChatResult: Result = Result.failure(RuntimeException("not configured")) var leaveChatResult: Result = Result.failure(RuntimeException("not configured")) var lastChatId: ChatId? = null @@ -351,6 +354,14 @@ private class FakeChatRepository : ChatRepository { return getGroupChatFeedResult } + override suspend fun startChat( + owner: Ed25519.KeyPair, + parameters: StartChatParameters, + idempotencyKey: IdempotencyKey, + ): Result { + return startChatResult + } + override suspend fun joinChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { lastChatId = chatId return joinChatResult