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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ChatMetadata> {
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<ChatMetadata> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,6 +113,33 @@ internal class ChatService @Inject constructor(
)
}

suspend fun startChat(
owner: KeyPair,
parameters: StartChatParameters,
idempotencyKey: IdempotencyKey,
): Result<ChatModel.Metadata> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,6 +53,14 @@ internal class InternalChatRepository(
)
}

override suspend fun startChat(
owner: KeyPair,
parameters: StartChatParameters,
idempotencyKey: IdempotencyKey,
): Result<ChatMetadata> = service.startChat(owner, parameters, idempotencyKey)
.onFailure { ErrorUtils.handleError(it) }
.map { mapper.map(it) }

override suspend fun joinChat(
owner: KeyPair,
chatId: ChatId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,6 +30,17 @@ interface ChatRepository {
queryOptions: QueryOptions,
): Result<ChatFeedPage>

/**
* 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<ChatMetadata>

/** Adds the caller to [chatId]'s roster, returning the chat as the caller now sees it. */
suspend fun joinChat(
owner: KeyPair,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -322,6 +324,7 @@ private class FakeChatRepository : ChatRepository {
var getChatResult: Result<ChatMetadata> = Result.failure(RuntimeException("not configured"))
var getDmChatFeedResult: Result<ChatFeedPage> = Result.failure(RuntimeException("not configured"))
var getGroupChatFeedResult: Result<ChatFeedPage> = Result.failure(RuntimeException("not configured"))
var startChatResult: Result<ChatMetadata> = Result.failure(RuntimeException("not configured"))
var joinChatResult: Result<ChatMetadata> = Result.failure(RuntimeException("not configured"))
var leaveChatResult: Result<Unit> = Result.failure(RuntimeException("not configured"))
var lastChatId: ChatId? = null
Expand Down Expand Up @@ -351,6 +354,14 @@ private class FakeChatRepository : ChatRepository {
return getGroupChatFeedResult
}

override suspend fun startChat(
owner: Ed25519.KeyPair,
parameters: StartChatParameters,
idempotencyKey: IdempotencyKey,
): Result<ChatMetadata> {
return startChatResult
}

override suspend fun joinChat(owner: Ed25519.KeyPair, chatId: ChatId): Result<ChatMetadata> {
lastChatId = chatId
return joinChatResult
Expand Down
Loading