From c255fe9df214f47ae20de87d717abcaa6387726c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 15:59:15 -0400 Subject: [PATCH 1/2] feat(chat): adopt flipcash2 contract ec66e6b (private groups, bio, cover picture) Rename StartChatRequest.group to public_group and add the private_group start option. Wire the seven lobby and key-envelope chat RPCs and the SetBio and SetCoverPicture profile RPCs through service, repository and controller, with result-enum to domain-error mappings. Map is_private, in_lobby, bio, cover_picture and ChatUpdate.lobby_updates, and the new ENCRYPTION_REQUIRED send and edit results. KeyEnvelope is carried as opaque bytes; no key wrapping or CHAT_KEY encryption is implemented. --- .../tipping/internal/CreateGroupViewModel.kt | 2 +- .../services/controllers/ChatController.kt | 68 +++++++ .../services/controllers/ProfileController.kt | 25 +++ .../internal/domain/ChatMetadataMapper.kt | 2 + .../internal/domain/UserProfileMapper.kt | 2 + .../services/internal/network/api/ChatApi.kt | 142 +++++++++++++- .../internal/network/api/ProfileApi.kt | 42 +++++ .../network/extensions/LocalToProtobuf.kt | 8 + .../network/extensions/ProtobufToLocal.kt | 70 +++++-- .../network/services/ChatMessagingService.kt | 2 + .../internal/network/services/ChatService.kt | 174 ++++++++++++++++++ .../network/services/ProfileService.kt | 45 +++++ .../repositories/InternalChatRepository.kt | 65 +++++++ .../repositories/InternalProfileRepository.kt | 24 +++ .../com/flipcash/services/models/Errors.kt | 115 ++++++++++++ .../flipcash/services/models/UserProfile.kt | 4 + .../services/models/chat/ChatMetadata.kt | 5 + .../services/models/chat/ChatUpdate.kt | 2 + .../services/models/chat/KeyEnvelope.kt | 46 +++++ .../flipcash/services/models/chat/Lobby.kt | 40 ++++ .../models/chat/StartChatParameters.kt | 16 +- .../services/repository/ChatRepository.kt | 47 +++++ .../services/repository/ProfileRepository.kt | 2 + .../controllers/ChatControllerTest.kt | 100 ++++++++++ .../controllers/ProfileControllerTest.kt | 40 ++++ 25 files changed, 1068 insertions(+), 20 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/KeyEnvelope.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Lobby.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index 7dc85e4f69..4e3333ce4b 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -71,7 +71,7 @@ internal val BalancePresets: List = listOf(Fiat(10), Fiat(50), Fiat(100)) * Two rules are enforced before the call rather than after it. The creator has to satisfy the rules * they are setting, so Create stays inert while the balance the rule measures is under the amount — * `StartChat` would answer `RULES_NOT_SATISFIED`, which is a worse way to learn it. And the picture - * has to be uploaded and `READY` first, because `GroupChatParameters.picture` is a blob id, not + * has to be uploaded and `READY` first, because `PublicGroupChatParameters.picture` is a blob id, not * bytes. Both server results are still handled: a balance can move between the check and the call. */ @HiltViewModel 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 ff5d74dbb2..2664b18f83 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 @@ -7,14 +7,19 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType 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.Lobby +import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.chat.StoredKeyEnvelope import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.core.ID import javax.inject.Inject import javax.inject.Singleton @@ -133,4 +138,67 @@ class ChatController @Inject constructor( return repository.unmuteChat(owner, chatId) } + + /** Enters private group [chatId]'s lobby. See [ChatRepository.enterLobby]. */ + suspend fun enterLobby(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.enterLobby(owner, chatId) + } + + /** Withdraws the caller from [chatId]'s lobby. */ + suspend fun leaveLobby(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.leaveLobby(owner, chatId) + } + + /** One page of [chatId]'s lobby. Creator only. */ + suspend fun getLobbyMembers( + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getLobbyMembers(owner, chatId, queryOptions) + } + + /** Admits [userId] from [chatId]'s lobby with the chat key wrapped for them in [keyEnvelope]. */ + suspend fun admitLobbyMember( + chatId: ChatId, + userId: ID, + keyEnvelope: KeyEnvelope, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.admitLobbyMember(owner, chatId, userId, keyEnvelope) + } + + /** Removes [userId] from [chatId]'s lobby without admitting them. */ + suspend fun denyLobbyMember(chatId: ChatId, userId: ID): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.denyLobbyMember(owner, chatId, userId) + } + + /** Stores the caller's own key envelope for [chatId]. */ + suspend fun setKeyEnvelope(chatId: ChatId, keyEnvelope: KeyEnvelope): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.setKeyEnvelope(owner, chatId, keyEnvelope) + } + + /** The caller's own key envelope for [chatId]. */ + suspend fun getKeyEnvelope(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getKeyEnvelope(owner, chatId) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index d0f0ec8cc3..e216c1d825 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -165,6 +165,31 @@ class ProfileController @Inject constructor( .onSuccess { media -> mergeLocalProfile { it.copy(profilePicture = media) } } } + /** + * Sets the caller's cover picture to a blob already uploaded via BlobStorage. + * Returns the full set of renditions the server derived from it. + */ + suspend fun setCoverPicture( + blobId: BlobId, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.setCoverPicture(blobId, owner) + .onSuccess { media -> mergeLocalProfile { it.copy(coverPicture = media) } } + } + + /** Sets the caller's bio, up to 160 characters. An empty [bio] clears it. */ + suspend fun setBio( + bio: String, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.setBio(bio, owner) + .onSuccess { mergeLocalProfile { it.copy(bio = bio) } } + } + // Applies [transform] to the locally cached profile (or a minimal one if none is cached yet) // and publishes it through UserManager. private fun mergeLocalProfile(transform: (UserProfile) -> UserProfile) { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index a789d7beae..d368878fda 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -53,6 +53,8 @@ class ChatMetadataMapper @Inject constructor( creator = if (from.hasCreator()) from.creator.toId() else null, // Transitional flag (see chat/v1 model.proto doc); ignored behaviourally for now. useE2ee = from.useE2Ee, + isPrivate = from.isPrivate, + inLobby = from.inLobby, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt index a0cce638da..ef4de7a26a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt @@ -33,6 +33,8 @@ class UserProfileMapper @Inject constructor( username = from.usernameOrNull?.value, minDmChatInitFee = if (from.hasMinDmChatInitFee()) from.minDmChatInitFee.toFiat() else null, isUsernameAutoAssigned = from.isUsernameAutoAssigned, + bio = from.bio, + coverPicture = if (from.hasCoverPicture()) from.coverPicture.toMediaItem() else null, ) } } \ No newline at end of file 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 77a26baeec..6f1d002797 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 @@ -10,6 +10,8 @@ import com.flipcash.services.internal.network.extensions.asProtoIdempotencyKey import com.flipcash.services.internal.network.extensions.asProtoMuteState import com.flipcash.services.internal.network.extensions.asProtoRules import com.flipcash.services.internal.network.extensions.asQueryOptions +import com.flipcash.services.internal.network.extensions.asProtoKeyEnvelope +import com.flipcash.services.internal.network.extensions.asUserId import com.flipcash.services.internal.network.extensions.asViewMode import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions @@ -17,11 +19,13 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType 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.StartChatParameters import com.flipcash.services.models.chat.ViewMode import com.getcode.utils.toByteString import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.model.core.ID import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow import io.grpc.ManagedChannel @@ -103,8 +107,8 @@ internal class ChatApi @Inject constructor( ): RpcChatService.StartChatResponse { val requestBuilder = RpcChatService.StartChatRequest.newBuilder() when (parameters) { - is StartChatParameters.Group -> requestBuilder.setGroup( - RpcChatService.StartChatRequest.GroupChatParameters.newBuilder() + is StartChatParameters.Group -> requestBuilder.setPublicGroup( + RpcChatService.StartChatRequest.PublicGroupChatParameters.newBuilder() .setTitle(parameters.title) .apply { parameters.picture?.let { @@ -116,6 +120,18 @@ internal class ChatApi @Inject constructor( } .apply { parameters.rules?.let { setRules(it.asProtoRules()) } } ) + is StartChatParameters.PrivateGroup -> requestBuilder.setPrivateGroup( + RpcChatService.StartChatRequest.PrivateGroupChatParameters.newBuilder() + .setTitle(parameters.title) + .apply { + parameters.picture?.let { + setPicture( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(it.bytes.toByteString()) + ) + } + } + ) } val request = requestBuilder @@ -263,4 +279,126 @@ internal class ChatApi @Inject constructor( api.unmuteChat(request) } } + + suspend fun enterLobby( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.EnterLobbyResponse { + val request = RpcChatService.EnterLobbyRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.enterLobby(request) + } + } + + suspend fun leaveLobby( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.LeaveLobbyResponse { + val request = RpcChatService.LeaveLobbyRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.leaveLobby(request) + } + } + + suspend fun getLobbyMembers( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): RpcChatService.GetLobbyMembersResponse { + val request = RpcChatService.GetLobbyMembersRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setQueryOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getLobbyMembers(request) + } + } + + suspend fun admitLobbyMember( + owner: KeyPair, + chatId: ChatId, + userId: ID, + keyEnvelope: KeyEnvelope, + ): RpcChatService.AdmitLobbyMemberResponse { + val request = RpcChatService.AdmitLobbyMemberRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setUserId(userId.asUserId()) + .setKeyEnvelope(keyEnvelope.asProtoKeyEnvelope()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.admitLobbyMember(request) + } + } + + suspend fun denyLobbyMember( + owner: KeyPair, + chatId: ChatId, + userId: ID, + ): RpcChatService.DenyLobbyMemberResponse { + val request = RpcChatService.DenyLobbyMemberRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setUserId(userId.asUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.denyLobbyMember(request) + } + } + + suspend fun setKeyEnvelope( + owner: KeyPair, + chatId: ChatId, + keyEnvelope: KeyEnvelope, + ): RpcChatService.SetKeyEnvelopeResponse { + val request = RpcChatService.SetKeyEnvelopeRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setKeyEnvelope(keyEnvelope.asProtoKeyEnvelope()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.setKeyEnvelope(request) + } + } + + suspend fun getKeyEnvelope( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.GetKeyEnvelopeResponse { + val request = RpcChatService.GetKeyEnvelopeRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getKeyEnvelope(request) + } + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt index bfee17a61f..0da8ac1a53 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt @@ -120,6 +120,48 @@ internal class ProfileApi @Inject constructor( } } + /** + * Sets the caller's cover picture to a blob they have already uploaded via BlobStorage, + * replacing any cover picture already set. + */ + suspend fun setCoverPicture( + blobId: BlobId, + owner: Ed25519.KeyPair, + ): ProfileService.SetCoverPictureResponse { + val request = ProfileService.SetCoverPictureRequest.newBuilder() + .setBlobId( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(blobId.bytes.toByteString()) + ) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.setCoverPicture(request) + } + } + + /** + * Sets the caller's bio, replacing any bio already set. An empty bio clears it. + */ + suspend fun setBio( + bio: String, + owner: Ed25519.KeyPair, + ): ProfileService.SetBioResponse { + val request = ProfileService.SetBioRequest.newBuilder() + .setBio(bio) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.setBio(request) + } + } + /** * links a social account to a user */ 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 631a687d9c..016f64a26a 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 @@ -301,3 +301,11 @@ internal fun MuteState.asProtoMuteState(): ChatModel.MuteState { MuteState.Forever -> builder.setForever(ChatModel.MuteState.Forever.getDefaultInstance()).build() } } + +internal fun com.flipcash.services.models.chat.KeyEnvelope.asProtoKeyEnvelope(): com.codeinc.flipcash.gen.chat.v1.Model.KeyEnvelope { + return com.codeinc.flipcash.gen.chat.v1.Model.KeyEnvelope.newBuilder() + .setSchemeValue(scheme) + .setNonce(nonce.toByteString()) + .setCiphertext(ciphertext.toByteString()) + .build() +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 3f17b074be..858176069b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -26,6 +26,9 @@ import com.flipcash.services.models.chat.ChatMutation import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.KeyEnvelope +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.services.models.chat.LobbyUpdate import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.blob.ImageConstraints import com.flipcash.services.models.blob.MimeTypeConstraints @@ -421,6 +424,8 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { creator = if (hasCreator()) creator.toId() else null, // Transitional flag (see chat/v1 model.proto doc); ignored behaviourally for now. useE2ee = useE2Ee, + isPrivate = isPrivate, + inLobby = inLobby, ) } @@ -452,28 +457,34 @@ internal fun ChatModel.MuteState.toMuteState(): MuteState { internal fun ChatModel.Member.toChatMember(): ChatMember { return ChatMember( userId = userId.toId(), - userProfile = with(userProfile) { - UserProfile( - displayName = displayName, - socialAccounts = emptyList(), - phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, - email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, - profilePicture = if (hasProfilePicture()) profilePicture.toMediaItem() else null, - // Falls back to the member's own id: the server sets it on the member but - // usually not again inside the nested profile, and this profile is by - // definition that member's. Dropping it here leaves callers unable to name - // the profile that authorizes re-minting the picture's download URL, so the - // avatar can never recover once the stored URL expires. - userId = if (hasUserId()) userId.toId() else this@toChatMember.userId.toId(), - username = if (hasUsername()) username.value else null, - ) - }, + // Falls back to the member's own id: the server sets it on the member but + // usually not again inside the nested profile, and this profile is by + // definition that member's. Dropping it here leaves callers unable to name + // the profile that authorizes re-minting the picture's download URL, so the + // avatar can never recover once the stored URL expires. + userProfile = userProfile.toMemberUserProfile(fallbackUserId = userId.toId()), pointers = pointersList.map { it.toPointer() }, joinedAt = if (hasJoinedAt()) Instant.fromEpochSeconds(joinedAt.seconds, joinedAt.nanos) else null, version = version, ) } +private fun com.codeinc.flipcash.gen.profile.v1.Model.UserProfile.toMemberUserProfile( + fallbackUserId: ID, +): UserProfile { + return UserProfile( + displayName = displayName, + socialAccounts = emptyList(), + phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, + email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, + profilePicture = if (hasProfilePicture()) profilePicture.toMediaItem() else null, + userId = if (hasUserId()) userId.toId() else fallbackUserId, + username = if (hasUsername()) username.value else null, + bio = bio, + coverPicture = if (hasCoverPicture()) coverPicture.toMediaItem() else null, + ) +} + // -- Chat roster summary -- internal fun ChatModel.RosterSummary.toRosterSummary(): RosterSummary { @@ -567,9 +578,36 @@ internal fun EventModel.ChatUpdate.toChatUpdate( rosterUpdates = if (hasRosterUpdates()) { rosterUpdates.rosterUpdatesList.mapNotNull { it.toRosterChangeOrNull(metadataMapper) } } else emptyList(), + lobbyUpdates = if (hasLobbyUpdates()) { + lobbyUpdates.lobbyUpdatesList.mapNotNull { it.toLobbyUpdateOrNull() } + } else emptyList(), ) } +// -- Lobby / key envelope -- + +internal fun ChatModel.KeyEnvelope.toKeyEnvelope(): KeyEnvelope = KeyEnvelope( + scheme = schemeValue, + nonce = nonce.toByteArray(), + ciphertext = ciphertext.toByteArray(), +) + +internal fun ChatModel.LobbyMember.toLobbyMember(): LobbyMember = LobbyMember( + // The server always sets the id on a lobby member's profile. + userProfile = userProfile.toMemberUserProfile(fallbackUserId = userProfile.userId.toId()), + publicKey = publicKey.toPublicKey(), + enteredAt = Instant.fromEpochSeconds(enteredAt.seconds, enteredAt.nanos), +) + +/** Null for a lobby update kind this client does not know, so the rest of the batch still applies. */ +internal fun ChatModel.LobbyUpdate.toLobbyUpdateOrNull(): LobbyUpdate? { + return when (kindCase) { + ChatModel.LobbyUpdate.KindCase.MEMBER_ENTERED -> LobbyUpdate.MemberEntered(memberEntered.member.toLobbyMember()) + ChatModel.LobbyUpdate.KindCase.MEMBER_LEFT -> LobbyUpdate.MemberLeft(memberLeft.userId.toId()) + else -> null + } +} + // -- EventModel.BlobUpdate -- internal fun EventModel.BlobUpdate.toBlobUpdate(): BlobUpdate { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt index 241de1c5d8..71e543e2f0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt @@ -130,6 +130,7 @@ internal class ChatMessagingService @Inject constructor( RpcMessagingService.SendMessageResponse.Result.OK -> Result.success(response.message) RpcMessagingService.SendMessageResponse.Result.DENIED -> Result.failure(SendMessageError.Denied()) RpcMessagingService.SendMessageResponse.Result.ENCRYPTION_NOT_ALLOWED -> Result.failure(SendMessageError.EncryptionNotAllowed()) + RpcMessagingService.SendMessageResponse.Result.ENCRYPTION_REQUIRED -> Result.failure(SendMessageError.EncryptionRequired()) RpcMessagingService.SendMessageResponse.Result.UNRECOGNIZED -> Result.failure(SendMessageError.Unrecognized()) else -> Result.failure(SendMessageError.Other()) } @@ -227,6 +228,7 @@ internal class ChatMessagingService @Inject constructor( RpcMessagingService.EditMessageResponse.Result.CANNOT_EDIT -> Result.failure(EditMessageError.CannotEdit()) RpcMessagingService.EditMessageResponse.Result.CONFLICT -> Result.failure(EditMessageError.Conflict()) RpcMessagingService.EditMessageResponse.Result.ENCRYPTION_NOT_ALLOWED -> Result.failure(EditMessageError.EncryptionNotAllowed()) + RpcMessagingService.EditMessageResponse.Result.ENCRYPTION_REQUIRED -> Result.failure(EditMessageError.EncryptionRequired()) RpcMessagingService.EditMessageResponse.Result.UNRECOGNIZED -> Result.failure(EditMessageError.Unrecognized()) else -> Result.failure(EditMessageError.Other()) } 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 ac19966081..977f6d9dba 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 @@ -4,7 +4,14 @@ 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.AdmitLobbyMemberError +import com.flipcash.services.models.DenyLobbyMemberError import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.EnterLobbyError +import com.flipcash.services.models.GetKeyEnvelopeError +import com.flipcash.services.models.GetLobbyMembersError +import com.flipcash.services.models.LeaveLobbyError +import com.flipcash.services.models.SetKeyEnvelopeError import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetDmChatFeedError import com.flipcash.services.models.GetGroupChatFeedError @@ -19,12 +26,14 @@ import com.flipcash.services.models.UnmuteChatError import com.flipcash.services.models.chat.ChatId 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.StartChatParameters import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewMode import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.model.core.ID import com.getcode.opencode.utils.toValidationOrElse import javax.inject.Inject @@ -286,4 +295,169 @@ internal class ChatService @Inject constructor( } ) } + + suspend fun enterLobby( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.enterLobby(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.EnterLobbyResponse.Result.OK -> Result.success(response.lobby) + RpcChatService.EnterLobbyResponse.Result.DENIED -> Result.failure(EnterLobbyError.Denied()) + RpcChatService.EnterLobbyResponse.Result.NOT_FOUND -> Result.failure(EnterLobbyError.NotFound()) + RpcChatService.EnterLobbyResponse.Result.ALREADY_MEMBER -> Result.failure(EnterLobbyError.AlreadyMember()) + RpcChatService.EnterLobbyResponse.Result.LOBBY_FULL -> Result.failure(EnterLobbyError.LobbyFull()) + RpcChatService.EnterLobbyResponse.Result.TOO_MANY_LOBBIES -> Result.failure(EnterLobbyError.TooManyLobbies()) + RpcChatService.EnterLobbyResponse.Result.UNRECOGNIZED -> Result.failure(EnterLobbyError.Unrecognized()) + else -> Result.failure(EnterLobbyError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { EnterLobbyError.Other(cause = it) }) + } + ) + } + + suspend fun leaveLobby( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.leaveLobby(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.LeaveLobbyResponse.Result.OK -> Result.success(Unit) + RpcChatService.LeaveLobbyResponse.Result.DENIED -> Result.failure(LeaveLobbyError.Denied()) + RpcChatService.LeaveLobbyResponse.Result.NOT_FOUND -> Result.failure(LeaveLobbyError.NotFound()) + RpcChatService.LeaveLobbyResponse.Result.UNRECOGNIZED -> Result.failure(LeaveLobbyError.Unrecognized()) + else -> Result.failure(LeaveLobbyError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { LeaveLobbyError.Other(cause = it) }) + } + ) + } + + suspend fun getLobbyMembers( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getLobbyMembers(owner, chatId, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetLobbyMembersResponse.Result.OK -> Result.success(response) + RpcChatService.GetLobbyMembersResponse.Result.DENIED -> Result.failure(GetLobbyMembersError.Denied()) + RpcChatService.GetLobbyMembersResponse.Result.NOT_FOUND -> Result.failure(GetLobbyMembersError.NotFound()) + RpcChatService.GetLobbyMembersResponse.Result.UNRECOGNIZED -> Result.failure(GetLobbyMembersError.Unrecognized()) + else -> Result.failure(GetLobbyMembersError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetLobbyMembersError.Other(cause = it) }) + } + ) + } + + suspend fun admitLobbyMember( + owner: KeyPair, + chatId: ChatId, + userId: ID, + keyEnvelope: KeyEnvelope, + ): Result { + return runCatching { + api.admitLobbyMember(owner, chatId, userId, keyEnvelope) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.AdmitLobbyMemberResponse.Result.OK -> Result.success(Unit) + RpcChatService.AdmitLobbyMemberResponse.Result.DENIED -> Result.failure(AdmitLobbyMemberError.Denied()) + RpcChatService.AdmitLobbyMemberResponse.Result.NOT_FOUND -> Result.failure(AdmitLobbyMemberError.NotFound()) + RpcChatService.AdmitLobbyMemberResponse.Result.NOT_IN_LOBBY -> Result.failure(AdmitLobbyMemberError.NotInLobby()) + RpcChatService.AdmitLobbyMemberResponse.Result.UNRECOGNIZED -> Result.failure(AdmitLobbyMemberError.Unrecognized()) + else -> Result.failure(AdmitLobbyMemberError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { AdmitLobbyMemberError.Other(cause = it) }) + } + ) + } + + suspend fun denyLobbyMember( + owner: KeyPair, + chatId: ChatId, + userId: ID, + ): Result { + return runCatching { + api.denyLobbyMember(owner, chatId, userId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.DenyLobbyMemberResponse.Result.OK -> Result.success(Unit) + RpcChatService.DenyLobbyMemberResponse.Result.DENIED -> Result.failure(DenyLobbyMemberError.Denied()) + RpcChatService.DenyLobbyMemberResponse.Result.NOT_FOUND -> Result.failure(DenyLobbyMemberError.NotFound()) + RpcChatService.DenyLobbyMemberResponse.Result.UNRECOGNIZED -> Result.failure(DenyLobbyMemberError.Unrecognized()) + else -> Result.failure(DenyLobbyMemberError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { DenyLobbyMemberError.Other(cause = it) }) + } + ) + } + + suspend fun setKeyEnvelope( + owner: KeyPair, + chatId: ChatId, + keyEnvelope: KeyEnvelope, + ): Result { + return runCatching { + api.setKeyEnvelope(owner, chatId, keyEnvelope) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.SetKeyEnvelopeResponse.Result.OK -> Result.success(Unit) + RpcChatService.SetKeyEnvelopeResponse.Result.DENIED -> Result.failure(SetKeyEnvelopeError.Denied()) + RpcChatService.SetKeyEnvelopeResponse.Result.NOT_FOUND -> Result.failure(SetKeyEnvelopeError.NotFound()) + RpcChatService.SetKeyEnvelopeResponse.Result.ALREADY_SET -> Result.failure(SetKeyEnvelopeError.AlreadySet()) + RpcChatService.SetKeyEnvelopeResponse.Result.UNRECOGNIZED -> Result.failure(SetKeyEnvelopeError.Unrecognized()) + else -> Result.failure(SetKeyEnvelopeError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { SetKeyEnvelopeError.Other(cause = it) }) + } + ) + } + + suspend fun getKeyEnvelope( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.getKeyEnvelope(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetKeyEnvelopeResponse.Result.OK -> Result.success(response) + RpcChatService.GetKeyEnvelopeResponse.Result.DENIED -> Result.failure(GetKeyEnvelopeError.Denied()) + RpcChatService.GetKeyEnvelopeResponse.Result.NOT_FOUND -> Result.failure(GetKeyEnvelopeError.NotFound()) + RpcChatService.GetKeyEnvelopeResponse.Result.NO_ENVELOPE -> Result.failure(GetKeyEnvelopeError.NoEnvelope()) + RpcChatService.GetKeyEnvelopeResponse.Result.UNRECOGNIZED -> Result.failure(GetKeyEnvelopeError.Unrecognized()) + else -> Result.failure(GetKeyEnvelopeError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetKeyEnvelopeError.Other(cause = it) }) + } + ) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt index e3d35fa603..8c4ab55269 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt @@ -8,6 +8,8 @@ import com.getcode.opencode.utils.toValidationOrElse import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.LinkSocialAccountError import com.flipcash.services.models.ProfileIdentifier +import com.flipcash.services.models.SetBioError +import com.flipcash.services.models.SetCoverPictureError import com.flipcash.services.models.SetDisplayNameError import com.flipcash.services.models.SetMinDmChatInitFeeError import com.flipcash.services.models.SetProfilePictureError @@ -115,6 +117,49 @@ internal class ProfileService @Inject constructor( ) } + suspend fun setCoverPicture( + blobId: BlobId, + owner: Ed25519.KeyPair, + ): Result { + return runCatching { + api.setCoverPicture(blobId, owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + ProfileService.SetCoverPictureResponse.Result.OK -> Result.success(response.coverPicture.toMediaItem()) + ProfileService.SetCoverPictureResponse.Result.DENIED -> Result.failure(SetCoverPictureError.Denied()) + ProfileService.SetCoverPictureResponse.Result.BLOB_NOT_FOUND -> Result.failure(SetCoverPictureError.BlobNotFound()) + ProfileService.SetCoverPictureResponse.Result.BLOB_NOT_READY -> Result.failure(SetCoverPictureError.BlobNotReady()) + ProfileService.SetCoverPictureResponse.Result.BLOB_REJECTED -> Result.failure(SetCoverPictureError.BlobRejected()) + ProfileService.SetCoverPictureResponse.Result.INVALID_BLOB -> Result.failure(SetCoverPictureError.InvalidBlob()) + ProfileService.SetCoverPictureResponse.Result.UNRECOGNIZED -> Result.failure(SetCoverPictureError.Unrecognized()) + } + }, + onFailure = { Result.failure(it.toValidationOrElse { cause -> SetCoverPictureError.Other(cause) }) } + ) + } + + suspend fun setBio( + bio: String, + owner: Ed25519.KeyPair, + ): Result { + return runCatching { + api.setBio(bio, owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + ProfileService.SetBioResponse.Result.OK -> Result.success(Unit) + ProfileService.SetBioResponse.Result.INVALID_BIO -> Result.failure(SetBioError.InvalidBio()) + ProfileService.SetBioResponse.Result.DENIED -> Result.failure(SetBioError.Denied()) + ProfileService.SetBioResponse.Result.FAILED_MODERATED -> + Result.failure(SetBioError.FailedModerated(response.flaggedCategory.toFlaggedCategory())) + ProfileService.SetBioResponse.Result.UNRECOGNIZED -> Result.failure(SetBioError.Unrecognized()) + } + }, + onFailure = { Result.failure(it.toValidationOrElse { cause -> SetBioError.Other(cause) }) } + ) + } + suspend fun updateTipCard( owner: Ed25519.KeyPair, hexColor: String, 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 b91923d7a6..81c19b5916 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 @@ -3,6 +3,9 @@ package com.flipcash.services.internal.repositories import com.flipcash.services.internal.domain.ChatMetadataMapper import com.flipcash.services.internal.domain.UserProfileMapper import com.flipcash.services.internal.network.extensions.toChatMember +import com.flipcash.services.internal.network.extensions.toId +import com.flipcash.services.internal.network.extensions.toKeyEnvelope +import com.flipcash.services.internal.network.extensions.toLobbyMember import com.flipcash.services.internal.network.extensions.toPagingToken import com.flipcash.services.internal.network.extensions.toRosterSummary import com.flipcash.services.internal.network.extensions.toViewerState @@ -15,14 +18,19 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType 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.Lobby +import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.chat.StoredKeyEnvelope import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.repository.ChatRepository import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.model.core.ID import com.getcode.utils.ErrorUtils import kotlin.time.Instant @@ -142,4 +150,61 @@ internal class InternalChatRepository( ): Result = service.unmuteChat(owner, chatId) .onFailure { ErrorUtils.handleError(it) } .map { it.toViewerState() } + + override suspend fun enterLobby(owner: KeyPair, chatId: ChatId): Result = + service.enterLobby(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + .map { lobby -> + Lobby( + chat = mapper.map(lobby.chat), + enteredAt = Instant.fromEpochSeconds(lobby.enteredAt.seconds, lobby.enteredAt.nanos), + ) + } + + override suspend fun leaveLobby(owner: KeyPair, chatId: ChatId): Result = + service.leaveLobby(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun getLobbyMembers( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result = service.getLobbyMembers(owner, chatId, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + LobbyPage( + members = response.membersList.map { it.toLobbyMember() }, + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + ) + } + + override suspend fun admitLobbyMember( + owner: KeyPair, + chatId: ChatId, + userId: ID, + keyEnvelope: KeyEnvelope, + ): Result = service.admitLobbyMember(owner, chatId, userId, keyEnvelope) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun denyLobbyMember(owner: KeyPair, chatId: ChatId, userId: ID): Result = + service.denyLobbyMember(owner, chatId, userId) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun setKeyEnvelope( + owner: KeyPair, + chatId: ChatId, + keyEnvelope: KeyEnvelope, + ): Result = service.setKeyEnvelope(owner, chatId, keyEnvelope) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun getKeyEnvelope(owner: KeyPair, chatId: ChatId): Result = + service.getKeyEnvelope(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + StoredKeyEnvelope( + envelope = response.keyEnvelope.toKeyEnvelope(), + wrappedBy = if (response.hasWrappedBy()) response.wrappedBy.toId() else null, + ) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt index e1886e66ad..053fe18879 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt @@ -5,6 +5,7 @@ import com.flipcash.services.internal.domain.UserProfileMapper import com.flipcash.services.internal.network.services.ProfileService import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.ProfileIdentifier +import com.flipcash.services.models.SetBioError import com.flipcash.services.models.SetDisplayNameError import com.flipcash.services.models.SetUsernameError import com.flipcash.services.models.SocialAccount @@ -79,6 +80,29 @@ internal class InternalProfileRepository( .onFailure { ErrorUtils.handleError(it) } } + override suspend fun setCoverPicture( + blobId: BlobId, + owner: Ed25519.KeyPair + ): Result { + return service.setCoverPicture(blobId, owner) + .onFailure { ErrorUtils.handleError(it) } + } + + override suspend fun setBio( + bio: String, + owner: Ed25519.KeyPair + ): Result { + return service.setBio(bio, owner) + .onFailure { + // The rejections below are the server answering a user's choice of bio, not a + // fault worth reporting. + val expected = it is SetBioError.InvalidBio || it is SetBioError.FailedModerated + if (!expected) { + ErrorUtils.handleError(it) + } + } + } + override suspend fun updateTipCard( owner: Ed25519.KeyPair, hexColor: String, 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 0fb89be241..d08521e426 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 @@ -241,6 +241,34 @@ sealed class SetProfilePictureError( data class Other(override val cause: Throwable? = null) : SetProfilePictureError(message = cause?.message, cause = cause), NotifiableError } +sealed class SetCoverPictureError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : SetCoverPictureError("Denied") + // No such blob, or it is not owned by the caller. + class BlobNotFound : SetCoverPictureError("Blob not found") + // Blob is still PENDING/PROCESSING; retry once READY. + class BlobNotReady : SetCoverPictureError("Blob not ready") + // Blob failed validation or moderation; terminal for this id, must upload again. + class BlobRejected : SetCoverPictureError("Blob rejected") + // Blob is READY but unusable as a picture (e.g. not an image). + class InvalidBlob : SetCoverPictureError("Invalid blob") + class Unrecognized : SetCoverPictureError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SetCoverPictureError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class SetBioError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class InvalidBio : SetBioError("Invalid bio") + class Denied : SetBioError("Denied") + class FailedModerated(val category: ModerationResult.FlaggedCategory) : SetBioError("Content flagged: $category") + class Unrecognized : SetBioError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SetBioError(message = cause?.message, cause = cause), NotifiableError +} + sealed class LinkSocialAccountError( override val message: String? = null, override val cause: Throwable? = null @@ -483,6 +511,9 @@ sealed class SendMessageError( // encrypts in a DM, so this means the chat's type changed under us; the send fails and can // be retried. class EncryptionNotAllowed : SendMessageError("Encryption not allowed") + // Sender sent plaintext to a chat that requires encryption (a private group). Not retryable + // as-is: the content has to be sealed with the chat key first, which this client cannot do yet. + class EncryptionRequired : SendMessageError("Encryption required") class Unrecognized : SendMessageError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : SendMessageError(message = cause?.message, cause = cause), NotifiableError } @@ -547,6 +578,8 @@ sealed class EditMessageError( // Editor sent EncryptedContent to a chat that doesn't allow it (non-DM). See // SendMessageError.EncryptionNotAllowed. class EncryptionNotAllowed : EditMessageError("Encryption not allowed") + // Editor sent plaintext to a chat that requires encryption. See SendMessageError.EncryptionRequired. + class EncryptionRequired : EditMessageError("Encryption required") class Unrecognized : EditMessageError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : EditMessageError(message = cause?.message, cause = cause), NotifiableError } @@ -733,6 +766,88 @@ sealed class UnmuteChatError( data class Other(override val cause: Throwable? = null) : UnmuteChatError(message = cause?.message, cause = cause), NotifiableError } +sealed class EnterLobbyError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + // Not a private group, or it has no key yet. + class Denied : EnterLobbyError("Denied") + class NotFound : EnterLobbyError("Not found") + class AlreadyMember : EnterLobbyError("Already a member") + class LobbyFull : EnterLobbyError("Lobby full") + // The caller is already waiting in as many lobbies as the server allows. + class TooManyLobbies : EnterLobbyError("Too many lobbies") + class Unrecognized : EnterLobbyError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : EnterLobbyError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class LeaveLobbyError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : LeaveLobbyError("Denied") + class NotFound : LeaveLobbyError("Not found") + class Unrecognized : LeaveLobbyError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : LeaveLobbyError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetLobbyMembersError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + // Caller is not the chat's creator, or the chat is not a private group. + class Denied : GetLobbyMembersError("Denied") + class NotFound : GetLobbyMembersError("Not found") + class Unrecognized : GetLobbyMembersError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetLobbyMembersError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class AdmitLobbyMemberError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : AdmitLobbyMemberError("Denied") + class NotFound : AdmitLobbyMemberError("Not found") + // The user is neither waiting in the lobby nor a member. + class NotInLobby : AdmitLobbyMemberError("Not in lobby") + class Unrecognized : AdmitLobbyMemberError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : AdmitLobbyMemberError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class DenyLobbyMemberError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : DenyLobbyMemberError("Denied") + class NotFound : DenyLobbyMemberError("Not found") + class Unrecognized : DenyLobbyMemberError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : DenyLobbyMemberError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class SetKeyEnvelopeError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : SetKeyEnvelopeError("Denied") + class NotFound : SetKeyEnvelopeError("Not found") + // The caller already stored a different envelope, which stands. + class AlreadySet : SetKeyEnvelopeError("Already set") + class Unrecognized : SetKeyEnvelopeError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SetKeyEnvelopeError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetKeyEnvelopeError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetKeyEnvelopeError("Denied") + class NotFound : GetKeyEnvelopeError("Not found") + // The caller is a member with no envelope stored. + class NoEnvelope : GetKeyEnvelopeError("No envelope") + class Unrecognized : GetKeyEnvelopeError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetKeyEnvelopeError(message = cause?.message, cause = cause), NotifiableError +} + sealed class ReportError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt index 2b66d203d9..a136ddb90e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt @@ -38,6 +38,10 @@ data class UserProfile( // own profile; false for anyone else's and when there is no username. Choosing // a different username clears it. val isUsernameAutoAssigned: Boolean = false, + // Free-text bio, up to 160 characters. Public. Empty when unset. + val bio: String = "", + // The user's cover picture, as the renditions it is stored as. Null when unset. + val coverPicture: MediaItem? = null, ): Parcelable { /** The phone number only when it has been verified — backwards-compatible accessor. */ val verifiedPhoneNumber: String? get() = phoneNumber?.takeIf { it.verified }?.value diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index c530d526a0..8e1b166d5f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -28,4 +28,9 @@ data class ChatMetadata( // Transitional E2EE flag (DMs only): true means clients should send new content as // EncryptedContent. Read only by E2eePolicy -- see chat/v1 model.proto. val useE2ee: Boolean = false, + // True for a private group: members are admitted from a lobby by the creator and messages + // are end-to-end encrypted. Fixed for the life of the chat. + val isPrivate: Boolean = false, + // Per-viewer: true while the viewer is waiting in this private group's lobby. + val inLobby: Boolean = false, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt index ecd3677ef6..1a80b27ba0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt @@ -10,4 +10,6 @@ data class ChatUpdate( // Convergent, like reactionUpdates and unlike events: applied by RosterSummary.version, // never gap-filled. See RosterChange. val rosterUpdates: List = emptyList(), + // Creator-only, best-effort lobby changes for a private group. Unversioned; see LobbyUpdate. + val lobbyUpdates: List = emptyList(), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/KeyEnvelope.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/KeyEnvelope.kt new file mode 100644 index 0000000000..42566d362f --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/KeyEnvelope.kt @@ -0,0 +1,46 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID + +/** + * A private group's chat key, wrapped for one user. Opaque to this layer: the bytes are carried + * to and from the server untouched. Wrapping and opening an envelope is a crypto concern that + * belongs to the chat cipher, not to the transport. + * + * [scheme] is the raw `KeyEnvelope.Scheme` number so an unrecognized scheme survives a round trip + * and the caller can treat it as an envelope it cannot open. + */ +class KeyEnvelope( + val scheme: Int, + val nonce: ByteArray, + val ciphertext: ByteArray, +) { + override fun equals(other: Any?): Boolean = + this === other || (other is KeyEnvelope && + scheme == other.scheme && + nonce.contentEquals(other.nonce) && + ciphertext.contentEquals(other.ciphertext)) + + override fun hashCode(): Int { + var result = scheme + result = 31 * result + nonce.contentHashCode() + result = 31 * result + ciphertext.contentHashCode() + return result + } + + override fun toString(): String = "KeyEnvelope(scheme=$scheme)" + + companion object { + /** `KeyEnvelope.Scheme.X25519_XCHACHA20POLY1305`. */ + const val SCHEME_X25519_XCHACHA20POLY1305 = 1 + } +} + +/** + * The caller's stored [envelope] and [wrappedBy], the user whose key wrapped it. Null + * [wrappedBy] when the server did not say. + */ +data class StoredKeyEnvelope( + val envelope: KeyEnvelope, + val wrappedBy: ID?, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Lobby.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Lobby.kt new file mode 100644 index 0000000000..d21e58c94d --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Lobby.kt @@ -0,0 +1,40 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.core.ID +import com.getcode.solana.keys.PublicKey +import kotlin.time.Instant + +/** + * A user waiting in a private group's lobby for its creator to admit them. Not a [ChatMember]. + * [publicKey] is the key the creator wraps the chat key for when admitting them. + */ +data class LobbyMember( + val userProfile: UserProfile, + val publicKey: PublicKey, + val enteredAt: Instant, +) + +/** A private group whose lobby the caller is waiting in. [chat] has `inLobby` set. */ +data class Lobby( + val chat: ChatMetadata, + val enteredAt: Instant, +) + +/** One page of a private group's lobby. Page with [pagingToken] while [hasMore]. */ +data class LobbyPage( + val members: List, + val pagingToken: PagingToken?, + val hasMore: Boolean, +) + +/** + * A best-effort change to a private group's lobby, delivered only to the group's creator on the + * event stream. Unversioned and outside the gap-detected event log: apply as received, and + * refetch the lobby when a miss is suspected. + */ +sealed interface LobbyUpdate { + data class MemberEntered(val member: LobbyMember) : LobbyUpdate + data class MemberLeft(val userId: ID) : LobbyUpdate +} 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 index 0f74750c0a..94a32952b4 100644 --- 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 @@ -3,7 +3,7 @@ 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 + * Only group chats (public or private) 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 { @@ -19,4 +19,18 @@ sealed interface StartChatParameters { */ val rules: ChatRules? = null, ) : StartChatParameters + + /** + * Parameters for creating a private group: the creator admits each member from a lobby and + * messages are end-to-end encrypted. Creation is two steps: after StartChat returns, the + * caller generates the chat key and stores its own envelope with `setKeyEnvelope`, retrying + * until it succeeds. Until then the group has no key and nothing can happen in it. A private + * group has no rules. + */ + data class PrivateGroup( + /** Title for the chat. */ + val title: String, + /** The blob holding the ORIGINAL picture the caller uploaded. Optional. */ + val picture: BlobId? = 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 b4d3f34ead..cfa104914c 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 @@ -1,19 +1,26 @@ package com.flipcash.services.repository +import com.flipcash.services.models.GetKeyEnvelopeError import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.SetKeyEnvelopeError 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.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey +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.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.chat.StoredKeyEnvelope import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.model.core.ID interface ChatRepository { suspend fun getChat( @@ -110,4 +117,44 @@ interface ChatRepository { owner: KeyPair, chatId: ChatId, ): Result + + /** + * Places the caller in private group [chatId]'s lobby, where the creator can admit or deny + * them. Returns the chat as a non-member sees it, with `inLobby` set. + */ + suspend fun enterLobby(owner: KeyPair, chatId: ChatId): Result + + /** Withdraws the caller from [chatId]'s lobby. */ + suspend fun leaveLobby(owner: KeyPair, chatId: ChatId): Result + + /** One page of the users waiting in [chatId]'s lobby. Creator only. */ + suspend fun getLobbyMembers( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): Result + + /** + * Admits [userId] from [chatId]'s lobby, storing [keyEnvelope] (the chat key wrapped for that + * user's public key) as part of the same call. The envelope is carried as opaque bytes. + */ + suspend fun admitLobbyMember( + owner: KeyPair, + chatId: ChatId, + userId: ID, + keyEnvelope: KeyEnvelope, + ): Result + + /** Removes [userId] from [chatId]'s lobby without admitting them. Creator only. */ + suspend fun denyLobbyMember(owner: KeyPair, chatId: ChatId, userId: ID): Result + + /** + * Stores the caller's own key envelope for [chatId]. [SetKeyEnvelopeError.AlreadySet] means a + * different envelope already stands; it is surfaced rather than recovered because whether to + * adopt the stored one is the caller's decision. + */ + suspend fun setKeyEnvelope(owner: KeyPair, chatId: ChatId, keyEnvelope: KeyEnvelope): Result + + /** The caller's own key envelope for [chatId], or [GetKeyEnvelopeError.NoEnvelope]. */ + suspend fun getKeyEnvelope(owner: KeyPair, chatId: ChatId): Result } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt index e9ab4e99e8..13a175663b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt @@ -15,6 +15,8 @@ interface ProfileRepository { suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result suspend fun setUsername(username: String, owner: Ed25519.KeyPair): Result suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair): Result + suspend fun setCoverPicture(blobId: BlobId, owner: Ed25519.KeyPair): Result + suspend fun setBio(bio: String, owner: Ed25519.KeyPair): Result suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String): Result suspend fun setMinDmChatInitFee(owner: Ed25519.KeyPair, fee: Fiat): Result suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair): Result 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 e27ff54ec4..b468f6e2f9 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 @@ -8,6 +8,10 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType 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.Lobby +import com.flipcash.services.models.chat.LobbyPage +import com.flipcash.services.models.chat.StoredKeyEnvelope import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.RosterSummary @@ -20,6 +24,7 @@ import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.ID import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -386,6 +391,41 @@ class ChatControllerTest { // endregion + // region lobby and key envelope + + @Test + fun `enterLobby fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + assertTrue(controller.enterLobby(ChatId(ByteArray(32))).isFailure) + } + + @Test + fun `admitLobbyMember forwards the envelope untouched`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 1 }) + val envelope = KeyEnvelope(scheme = 1, nonce = ByteArray(24) { 2 }, ciphertext = ByteArray(48) { 3 }) + + val result = controller.admitLobbyMember(chatId, listOf(9), envelope) + + assertTrue(result.isSuccess) + assertEquals(chatId, repository.lastChatId) + assertSame(envelope, repository.lastKeyEnvelope) + } + + @Test + fun `setKeyEnvelope surfaces repository failures`() = runTest { + stubOwner() + val cause = RuntimeException("already set") + repository.setKeyEnvelopeResult = Result.failure(cause) + + val result = controller.setKeyEnvelope(ChatId(ByteArray(32)), KeyEnvelope(1, ByteArray(24), ByteArray(48))) + + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + // region leaveChat @Test @@ -552,6 +592,66 @@ private class FakeChatRepository : ChatRepository { lastChatId = chatId return unmuteChatResult } + + var enterLobbyResult: Result = Result.failure(RuntimeException("not configured")) + var leaveLobbyResult: Result = Result.success(Unit) + var getLobbyMembersResult: Result = Result.failure(RuntimeException("not configured")) + var admitLobbyMemberResult: Result = Result.success(Unit) + var denyLobbyMemberResult: Result = Result.success(Unit) + var setKeyEnvelopeResult: Result = Result.success(Unit) + var getKeyEnvelopeResult: Result = Result.failure(RuntimeException("not configured")) + var lastKeyEnvelope: KeyEnvelope? = null + + override suspend fun enterLobby(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastChatId = chatId + return enterLobbyResult + } + + override suspend fun leaveLobby(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastChatId = chatId + return leaveLobbyResult + } + + override suspend fun getLobbyMembers( + owner: Ed25519.KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result { + lastChatId = chatId + lastQueryOptions = queryOptions + return getLobbyMembersResult + } + + override suspend fun admitLobbyMember( + owner: Ed25519.KeyPair, + chatId: ChatId, + userId: ID, + keyEnvelope: KeyEnvelope, + ): Result { + lastChatId = chatId + lastKeyEnvelope = keyEnvelope + return admitLobbyMemberResult + } + + override suspend fun denyLobbyMember(owner: Ed25519.KeyPair, chatId: ChatId, userId: ID): Result { + lastChatId = chatId + return denyLobbyMemberResult + } + + override suspend fun setKeyEnvelope( + owner: Ed25519.KeyPair, + chatId: ChatId, + keyEnvelope: KeyEnvelope, + ): Result { + lastChatId = chatId + lastKeyEnvelope = keyEnvelope + return setKeyEnvelopeResult + } + + override suspend fun getKeyEnvelope(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastChatId = chatId + return getKeyEnvelopeResult + } } // endregion diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt index 2376744dff..8118af6d55 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt @@ -1,6 +1,7 @@ package com.flipcash.services.controllers import com.flipcash.services.models.GetUserProfileError +import com.flipcash.services.models.SetBioError import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest @@ -259,6 +260,41 @@ class ProfileControllerTest { verify { userManager.set(match { it.username == "chosen" && !it.isUsernameAutoAssigned }) } } + @Test + fun `setBio caches the bio on success`() = runTest { + stubOwner() + every { userManager.profile } returns stubProfile() + + val result = controller.setBio("hello") + + assertTrue(result.isSuccess) + verify { userManager.set(match { it.bio == "hello" }) } + } + + @Test + fun `setBio leaves the cached profile alone on failure`() = runTest { + stubOwner() + every { userManager.profile } returns stubProfile() + repository.setBioResult = Result.failure(SetBioError.InvalidBio()) + + val result = controller.setBio("hello") + + assertTrue(result.isFailure) + verify(exactly = 0) { userManager.set(any()) } + } + + @Test + fun `setCoverPicture caches the returned media`() = runTest { + stubOwner() + every { userManager.profile } returns stubProfile() + val media = MediaItem(renditions = emptyList()) + repository.setCoverPictureResult = Result.success(media) + + controller.setCoverPicture(BlobId(byteArrayOf(1))) + + verify { userManager.set(match { it.coverPicture == media }) } + } + // endregion // region linkTwitterXAccount @@ -348,6 +384,8 @@ private class FakeProfileRepository : ProfileRepository { var setDisplayNameResult: Result = Result.success(null) var setUsernameResult: Result = Result.success(Unit) var setProfilePictureResult: Result = Result.failure(RuntimeException("not configured")) + var setCoverPictureResult: Result = Result.failure(RuntimeException("not configured")) + var setBioResult: Result = Result.success(Unit) var updateTipCardResult: Result = Result.success(Unit) var setMinDmChatInitFeeResult: Result = Result.success(Unit) var linkSocialAccountResult: Result = Result.failure(RuntimeException("not configured")) @@ -357,6 +395,8 @@ private class FakeProfileRepository : ProfileRepository { override suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair) = setDisplayNameResult override suspend fun setUsername(username: String, owner: Ed25519.KeyPair) = setUsernameResult override suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair) = setProfilePictureResult + override suspend fun setCoverPicture(blobId: BlobId, owner: Ed25519.KeyPair) = setCoverPictureResult + override suspend fun setBio(bio: String, owner: Ed25519.KeyPair) = setBioResult override suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String) = updateTipCardResult override suspend fun setMinDmChatInitFee(owner: Ed25519.KeyPair, fee: Fiat) = setMinDmChatInitFeeResult override suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair) = From 9f7c46cbc4d2c35511f5903648cfa1faa77a624d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 16:04:44 -0400 Subject: [PATCH 2/2] chore(deps): bump flipcash2-client-protocol to 0.16.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f40558fbec..c3e20e2977 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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.6.0" -flipcash2-client-protocol = "0.15.0" +flipcash2-client-protocol = "0.16.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,