Skip to content

Commit 08a6ff8

Browse files
authored
feat(chat): scaffold StartChat with a required idempotency key (#1473)
* feat(chat): scaffold StartChat RPC with required idempotency key flipcash2 sync e1f4116c makes idempotency_key a required field on StartChatRequest (upstream #102). Android had no StartChat call site to update, so this scaffolds the full chain instead: ChatApi.startChat builds and validates the request, ChatService maps the response Result enum to a new StartChatError sealed class, ChatRepository / InternalChatRepository add the domain-facing method, and ChatController exposes it. New IdempotencyKey and StartChatParameters domain types back the call. idempotencyKey is a required ChatController.startChat parameter with no default, so whichever caller originates a "create chat" action has to mint and reuse the same key across its own retries; nothing here generates one per call. Compiles: services:flipcash:compileDebugKotlin and compileDebugUnitTestKotlin against the local flipcash2-client-protocol override. * chore(deps): pin flipcash2-client-protocol to 0.8.0 StartChat needs the idempotency_key field that 0.8.0 adds. Blocked until that version is on Maven Central.
1 parent e03477f commit 08a6ff8

11 files changed

Lines changed: 238 additions & 2 deletions

File tree

‎gradle/libs.versions.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ protovalidate-kt = "0.1.2"
6969
# 0.3.0 is the first release of either package to ship R8 keep rules for its generated
7070
# messages, which is what lets proguard-rules.pro drop its own.
7171
ocp-client-protocol = "0.4.0"
72-
flipcash2-client-protocol = "0.7.0"
72+
flipcash2-client-protocol = "0.8.0"
7373

7474
# The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's
7575
# `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata,

‎services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import com.flipcash.services.models.chat.ChatFeedPage
55
import com.flipcash.services.models.chat.ChatId
66
import com.flipcash.services.models.chat.ChatMetadata
77
import com.flipcash.services.models.chat.ChatType
8+
import com.flipcash.services.models.chat.IdempotencyKey
9+
import com.flipcash.services.models.chat.StartChatParameters
810
import com.flipcash.services.repository.ChatRepository
911
import com.flipcash.services.user.UserManager
1012
import javax.inject.Inject
@@ -41,6 +43,25 @@ class ChatController @Inject constructor(
4143
return repository.getGroupChatFeed(owner, queryOptions)
4244
}
4345

46+
/**
47+
* Starts a new chat from [parameters].
48+
*
49+
* [idempotencyKey] is deliberately a required parameter rather than something this method
50+
* mints itself: for the retry safety to hold, the *caller* must mint it once where the
51+
* intent to start the chat originates (e.g. when the user taps "Create") and pass the same
52+
* instance again on every retry of that attempt. Minting a fresh key here would make every
53+
* retry look like a brand-new chat to the server.
54+
*/
55+
suspend fun startChat(
56+
parameters: StartChatParameters,
57+
idempotencyKey: IdempotencyKey,
58+
): Result<ChatMetadata> {
59+
val owner = userManager.accountCluster?.authority?.keyPair
60+
?: return Result.failure(Throwable("No account cluster in UserManager"))
61+
62+
return repository.startChat(owner, parameters, idempotencyKey)
63+
}
64+
4465
suspend fun joinChat(chatId: ChatId): Result<ChatMetadata> {
4566
val owner = userManager.accountCluster?.authority?.keyPair
4667
?: return Result.failure(Throwable("No account cluster in UserManager"))

‎services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ import com.codeinc.flipcash.gen.chat.v1.validate
66
import com.flipcash.services.internal.annotations.FlipcashManagedChannel
77
import com.flipcash.services.internal.network.extensions.asChatId
88
import com.flipcash.services.internal.network.extensions.asProtoChatType
9+
import com.flipcash.services.internal.network.extensions.asProtoIdempotencyKey
10+
import com.flipcash.services.internal.network.extensions.asProtoRules
911
import com.flipcash.services.internal.network.extensions.asQueryOptions
1012
import com.flipcash.services.internal.network.extensions.authenticate
1113
import com.flipcash.services.models.QueryOptions
1214
import com.flipcash.services.models.chat.ChatId
1315
import com.flipcash.services.models.chat.ChatType
16+
import com.flipcash.services.models.chat.IdempotencyKey
17+
import com.flipcash.services.models.chat.StartChatParameters
18+
import com.getcode.utils.toByteString
1419
import com.getcode.ed25519.Ed25519.KeyPair
1520
import com.getcode.opencode.internal.network.core.GrpcApi
1621
import dev.bmcreations.protovalidate.orThrow
@@ -79,6 +84,45 @@ internal class ChatApi @Inject constructor(
7984
}
8085
}
8186

87+
/**
88+
* Starts a new chat. [idempotencyKey] must be minted by the caller — once per logical
89+
* attempt — and reused across retries of the same attempt; generating a fresh key per call
90+
* defeats the retry-safety the server offers.
91+
*/
92+
suspend fun startChat(
93+
owner: KeyPair,
94+
parameters: StartChatParameters,
95+
idempotencyKey: IdempotencyKey,
96+
): RpcChatService.StartChatResponse {
97+
val requestBuilder = RpcChatService.StartChatRequest.newBuilder()
98+
when (parameters) {
99+
is StartChatParameters.Group -> requestBuilder.setGroup(
100+
RpcChatService.StartChatRequest.GroupChatParameters.newBuilder()
101+
.setTitle(parameters.title)
102+
.apply {
103+
parameters.picture?.let {
104+
setPicture(
105+
com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder()
106+
.setValue(it.bytes.toByteString())
107+
)
108+
}
109+
}
110+
.apply { parameters.rules?.let { setRules(it.asProtoRules()) } }
111+
)
112+
}
113+
114+
val request = requestBuilder
115+
.setIdempotencyKey(idempotencyKey.asProtoIdempotencyKey())
116+
.apply { setAuth(authenticate(owner)) }
117+
.build()
118+
119+
request.validate().orThrow()
120+
121+
return withContext(Dispatchers.IO) {
122+
api.startChat(request)
123+
}
124+
}
125+
82126
suspend fun joinChat(
83127
owner: KeyPair,
84128
chatId: ChatId,

‎services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import com.flipcash.services.models.PagingToken
88
import com.flipcash.services.models.QueryOptions
99
import com.flipcash.services.models.SocialAccountLinkRequest
1010
import com.flipcash.services.models.chat.ChatId
11+
import com.flipcash.services.models.chat.ChatRuleRequirement
12+
import com.flipcash.services.models.chat.ChatRules
1113
import com.flipcash.services.models.chat.ChatType
1214
import com.flipcash.services.models.chat.ClientMessageId
15+
import com.flipcash.services.models.chat.IdempotencyKey
1316
import com.flipcash.services.models.chat.MessageContent
1417
import com.flipcash.services.models.chat.PointerType
1518
import com.flipcash.services.models.chat.TypingState
@@ -228,4 +231,37 @@ internal fun TypingState.asTypingState(): MessagingModel.IsTypingNotification.St
228231
TypingState.TYPING_TIMED_OUT -> MessagingModel.IsTypingNotification.State.TYPING_TIMED_OUT
229232
TypingState.UNKNOWN -> MessagingModel.IsTypingNotification.State.UNKNOWN_TYPING_STATE
230233
}
231-
}
234+
}
235+
internal fun IdempotencyKey.asProtoIdempotencyKey(): ChatModel.IdempotencyKey {
236+
return ChatModel.IdempotencyKey.newBuilder().setValue(bytes.toByteString()).build()
237+
}
238+
239+
internal fun ChatRules.asProtoRules(): ChatModel.Rules {
240+
return ChatModel.Rules.newBuilder()
241+
.addAllListener(listener.map { it.asProtoListenerRules() })
242+
.addAllSpeaker(speaker.map { it.asProtoSpeakerRules() })
243+
.build()
244+
}
245+
246+
internal fun ChatRuleRequirement.asProtoListenerRules(): ChatModel.ListenerRules {
247+
val builder = ChatModel.ListenerRules.newBuilder()
248+
return when (this) {
249+
is ChatRuleRequirement.MinimumBalance -> builder.setMinimumBalance(asProtoMinimumBalanceRequirement()).build()
250+
ChatRuleRequirement.Staff -> builder.setStaff(ChatModel.StaffRequirement.getDefaultInstance()).build()
251+
}
252+
}
253+
254+
internal fun ChatRuleRequirement.asProtoSpeakerRules(): ChatModel.SpeakerRules {
255+
val builder = ChatModel.SpeakerRules.newBuilder()
256+
return when (this) {
257+
is ChatRuleRequirement.MinimumBalance -> builder.setMinimumBalance(asProtoMinimumBalanceRequirement()).build()
258+
ChatRuleRequirement.Staff -> builder.setStaff(ChatModel.StaffRequirement.getDefaultInstance()).build()
259+
}
260+
}
261+
262+
internal fun ChatRuleRequirement.MinimumBalance.asProtoMinimumBalanceRequirement(): ChatModel.MinimumBalanceRequirement {
263+
return ChatModel.MinimumBalanceRequirement.newBuilder()
264+
.setAmount(amount.asFiatPaymentAmount())
265+
.addAllMints(mints.map { it.asPublicKey() })
266+
.build()
267+
}

‎services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ package com.flipcash.services.internal.network.services
33
import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService
44
import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel
55
import com.flipcash.services.internal.network.api.ChatApi
6+
import com.flipcash.services.internal.network.extensions.toFlaggedCategory
67
import com.flipcash.services.models.GetChatError
78
import com.flipcash.services.models.GetDmChatFeedError
89
import com.flipcash.services.models.GetGroupChatFeedError
910
import com.flipcash.services.models.JoinChatError
1011
import com.flipcash.services.models.LeaveChatError
1112
import com.flipcash.services.models.QueryOptions
13+
import com.flipcash.services.models.StartChatError
1214
import com.flipcash.services.models.chat.ChatId
15+
import com.flipcash.services.models.chat.IdempotencyKey
16+
import com.flipcash.services.models.chat.StartChatParameters
1317
import com.flipcash.services.models.chat.ChatType
1418
import com.getcode.ed25519.Ed25519.KeyPair
1519
import com.getcode.opencode.internal.network.extensions.foldWithSuppression
@@ -109,6 +113,33 @@ internal class ChatService @Inject constructor(
109113
)
110114
}
111115

116+
suspend fun startChat(
117+
owner: KeyPair,
118+
parameters: StartChatParameters,
119+
idempotencyKey: IdempotencyKey,
120+
): Result<ChatModel.Metadata> {
121+
return runCatching {
122+
api.startChat(owner, parameters, idempotencyKey)
123+
}.foldWithSuppression(
124+
onSuccess = { response ->
125+
when (response.result) {
126+
RpcChatService.StartChatResponse.Result.OK -> Result.success(response.chat)
127+
RpcChatService.StartChatResponse.Result.DENIED -> Result.failure(StartChatError.Denied())
128+
RpcChatService.StartChatResponse.Result.TITLE_MODERATED ->
129+
Result.failure(StartChatError.TitleModerated(response.flaggedCategory.toFlaggedCategory()))
130+
RpcChatService.StartChatResponse.Result.PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(StartChatError.PictureBlobNotAccepted())
131+
RpcChatService.StartChatResponse.Result.INVALID_RULES -> Result.failure(StartChatError.InvalidRules())
132+
RpcChatService.StartChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(StartChatError.RulesNotSatisfied())
133+
RpcChatService.StartChatResponse.Result.UNRECOGNIZED -> Result.failure(StartChatError.Unrecognized())
134+
else -> Result.failure(StartChatError.Other())
135+
}
136+
},
137+
onFailure = { cause ->
138+
Result.failure(cause.toValidationOrElse { StartChatError.Other(cause = it) })
139+
}
140+
)
141+
}
142+
112143
suspend fun leaveChat(
113144
owner: KeyPair,
114145
chatId: ChatId,

‎services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import com.flipcash.services.models.chat.ChatFeedPage
99
import com.flipcash.services.models.chat.ChatId
1010
import com.flipcash.services.models.chat.ChatMetadata
1111
import com.flipcash.services.models.chat.ChatType
12+
import com.flipcash.services.models.chat.IdempotencyKey
13+
import com.flipcash.services.models.chat.StartChatParameters
1214
import com.flipcash.services.repository.ChatRepository
1315
import com.getcode.ed25519.Ed25519.KeyPair
1416
import com.getcode.utils.ErrorUtils
@@ -51,6 +53,14 @@ internal class InternalChatRepository(
5153
)
5254
}
5355

56+
override suspend fun startChat(
57+
owner: KeyPair,
58+
parameters: StartChatParameters,
59+
idempotencyKey: IdempotencyKey,
60+
): Result<ChatMetadata> = service.startChat(owner, parameters, idempotencyKey)
61+
.onFailure { ErrorUtils.handleError(it) }
62+
.map { mapper.map(it) }
63+
5464
override suspend fun joinChat(
5565
owner: KeyPair,
5666
chatId: ChatId,

‎services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,22 @@ sealed class LeaveChatError(
396396
data class Other(override val cause: Throwable? = null) : LeaveChatError(message = cause?.message, cause = cause), NotifiableError
397397
}
398398

399+
sealed class StartChatError(
400+
override val message: String? = null,
401+
override val cause: Throwable? = null
402+
): CodeServerError(message, cause) {
403+
class Denied : StartChatError("Denied")
404+
class TitleModerated(val category: ModerationResult.FlaggedCategory) : StartChatError("Title flagged: $category")
405+
class PictureBlobNotAccepted : StartChatError("Picture blob not accepted")
406+
class InvalidRules : StartChatError("Invalid rules")
407+
// The caller does not meet the rules they are setting on the chat being created (e.g. a
408+
// minimum balance requirement). Recoverable by the user, so deliberately not a
409+
// NotifiableError, matching JoinChatError.RulesNotSatisfied.
410+
class RulesNotSatisfied : StartChatError("Rules not satisfied")
411+
class Unrecognized : StartChatError("Unrecognized"), NotifiableError
412+
data class Other(override val cause: Throwable? = null) : StartChatError(message = cause?.message, cause = cause), NotifiableError
413+
}
414+
399415
sealed class GetMessageError(
400416
override val message: String? = null,
401417
override val cause: Throwable? = null
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.flipcash.services.models.chat
2+
3+
import kotlinx.serialization.Serializable
4+
import java.security.SecureRandom
5+
6+
/**
7+
* A client-minted key that makes a request that creates something safe to retry.
8+
*
9+
* The server derives the created object's identity from the caller and this key, so a retry with
10+
* the same key returns the original result instead of creating a duplicate — even if the retry's
11+
* parameters differ from the first attempt's.
12+
*
13+
* Mint one [random] key where the intent that creates the object originates (e.g. the moment the
14+
* user taps "Create"), and carry that same instance through every retry of that one attempt.
15+
* Generating a fresh key per retry defeats the point: each retry would look like a brand-new
16+
* request to the server.
17+
*/
18+
@Serializable
19+
@JvmInline
20+
value class IdempotencyKey(val bytes: ByteArray) {
21+
22+
companion object {
23+
private const val SIZE_BYTES = 16
24+
25+
/** Mints a new random key. Call once per logical attempt; reuse it across retries. */
26+
fun random(): IdempotencyKey {
27+
val bytes = ByteArray(SIZE_BYTES)
28+
SecureRandom().nextBytes(bytes)
29+
return IdempotencyKey(bytes)
30+
}
31+
}
32+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.flipcash.services.models.chat
2+
3+
/**
4+
* Parameters for starting a new chat. The variant selects the kind of chat created.
5+
*
6+
* Only group chats are creatable through StartChat today; DM chats are opened by resolving a
7+
* [ChatId] instead and come into existence on the server the first time a message is sent.
8+
*/
9+
sealed interface StartChatParameters {
10+
/** Parameters for creating a group chat. */
11+
data class Group(
12+
/** Title for the chat. */
13+
val title: String,
14+
/** The blob holding the ORIGINAL picture the caller uploaded. Optional. */
15+
val picture: BlobId? = null,
16+
/**
17+
* Participation requirements for the chat. Optional; if not set, the chat has no
18+
* participation requirements. The caller must satisfy the rules for the chat to start.
19+
*/
20+
val rules: ChatRules? = null,
21+
) : StartChatParameters
22+
}

‎services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import com.flipcash.services.models.chat.ChatFeedPage
55
import com.flipcash.services.models.chat.ChatId
66
import com.flipcash.services.models.chat.ChatMetadata
77
import com.flipcash.services.models.chat.ChatType
8+
import com.flipcash.services.models.chat.IdempotencyKey
9+
import com.flipcash.services.models.chat.StartChatParameters
810
import com.getcode.ed25519.Ed25519.KeyPair
911

1012
interface ChatRepository {
@@ -28,6 +30,17 @@ interface ChatRepository {
2830
queryOptions: QueryOptions,
2931
): Result<ChatFeedPage>
3032

33+
/**
34+
* Starts a new chat from [parameters]. [idempotencyKey] must be minted by the caller once
35+
* per logical attempt and reused across retries of that attempt — a retry with the same key
36+
* returns the originally created chat (result OK) even if [parameters] differ.
37+
*/
38+
suspend fun startChat(
39+
owner: KeyPair,
40+
parameters: StartChatParameters,
41+
idempotencyKey: IdempotencyKey,
42+
): Result<ChatMetadata>
43+
3144
/** Adds the caller to [chatId]'s roster, returning the chat as the caller now sees it. */
3245
suspend fun joinChat(
3346
owner: KeyPair,

0 commit comments

Comments
 (0)