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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,15 @@ sealed interface MessageContentSerialized {
data class WidgetPaymentRequest(val paymentRequestId: String) : MessageContentSerialized

// Mirrors MessageContent.System. [joinedUser] is set for a UserJoined event, and
// [admittedBy] when someone let them in; both hex-encoded. Rows stored before the event was
// [admittedBy] when someone let them in or [invitedBy] when they used someone's invite; each hex-encoded. Rows stored before the event was
// kept have neither and decode as fallback-only.
@Serializable
@SerialName("system")
data class System(
val fallbackText: String,
val joinedUser: String? = null,
val admittedBy: String? = null,
val invitedBy: String? = null,
) : MessageContentSerialized

// Mirrors MessageContent.Unsupported: a content type the build that stored it didn't know.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
fallbackText = fallbackText,
joinedUser = event.userId.hexEncodedString(),
admittedBy = event.admittedBy?.hexEncodedString(),
invitedBy = event.invitedBy?.hexEncodedString(),
)
null -> MessageContentSerialized.System(fallbackText = fallbackText)
}
Expand Down Expand Up @@ -485,7 +486,7 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
)
is MessageContentSerialized.System -> MessageContent.System(
fallbackText = fallbackText,
event = joinedUser?.let { MessageContent.SystemEvent.UserJoined(it.hexToIdExt(), admittedBy?.hexToIdExt()) },
event = joinedUser?.let { MessageContent.SystemEvent.UserJoined(it.hexToIdExt(), admittedBy?.hexToIdExt(), invitedBy?.hexToIdExt()) },
)
MessageContentSerialized.Unsupported -> MessageContent.Unsupported
is MessageContentSerialized.Deleted -> MessageContent.Deleted(
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ protovalidate-kt = "0.1.3"
# 0.3.0 is the first release of either package to ship R8 keep rules for its generated
# messages, which is what lets proguard-rules.pro drop its own.
ocp-client-protocol = "0.9.0"
flipcash2-client-protocol = "0.21.0"
flipcash2-client-protocol = "0.22.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 @@ -8,6 +8,14 @@ import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.ChatterSample
import com.flipcash.services.models.chat.EditChatParameters
import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.chat.Invite
import com.flipcash.services.models.chat.InviteId
import com.flipcash.services.models.chat.InviteKeyEnvelope
import com.flipcash.services.models.chat.InviteParameters
import com.flipcash.services.models.chat.InvitePreview
import com.flipcash.services.models.chat.InviteSecretEnvelope
import com.flipcash.services.models.chat.InviteToken
import com.flipcash.services.models.chat.InvitesPage
import com.flipcash.services.models.chat.KeyEnvelope
import com.flipcash.services.models.chat.Lobby
import com.flipcash.services.models.chat.LobbyPage
Expand All @@ -25,6 +33,7 @@ import com.flipcash.services.user.UserManager
import com.getcode.opencode.model.core.ID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant

@Singleton
class ChatController @Inject constructor(
Expand Down Expand Up @@ -86,11 +95,15 @@ class ChatController @Inject constructor(
suspend fun getRoster(
chatId: ChatId,
queryOptions: QueryOptions = QueryOptions(),
inviteToken: InviteToken? = null,
): Result<RosterPage> {
// With an invite token the account is optional: the token stands in for auth.
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
if (owner == null && inviteToken == null) {
return Result.failure(Throwable("No account cluster in UserManager"))
}

return repository.getRoster(owner, chatId, queryOptions)
return repository.getRoster(owner, chatId, queryOptions, inviteToken)
}

/**
Expand Down Expand Up @@ -135,11 +148,12 @@ class ChatController @Inject constructor(
return repository.editChat(owner, chatId, parameters)
}

suspend fun joinChat(chatId: ChatId): Result<ChatMetadata> {
/** Joins [chatId], with [invite] when joining a private group through a link. */
suspend fun joinChat(chatId: ChatId, invite: InviteParameters? = null): Result<ChatMetadata> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.joinChat(owner, chatId)
return repository.joinChat(owner, chatId, invite)
}

suspend fun leaveChat(chatId: ChatId): Result<Unit> {
Expand Down Expand Up @@ -267,4 +281,41 @@ class ChatController @Inject constructor(

return repository.getKeyEnvelope(owner, chatId)
}

/** Creates an invite link for private group [chatId]; see [ChatRepository.createInvite]. */
suspend fun createInvite(
chatId: ChatId,
token: InviteToken,
keyEnvelope: InviteKeyEnvelope,
secretEnvelope: InviteSecretEnvelope,
maxUses: Int = 0,
expiresAt: Instant? = null,
): Result<Invite> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.createInvite(owner, chatId, token, keyEnvelope, secretEnvelope, maxUses, expiresAt)
}

/** What [token]'s link leads to. Works without an account cluster: an anonymous viewer can look an invite up. */
suspend fun getInvite(token: InviteToken): Result<InvitePreview> {
val owner = userManager.accountCluster?.authority?.keyPair
return repository.getInvite(owner, token)
}

/** One page of [chatId]'s invites. */
suspend fun getChatInvites(chatId: ChatId, queryOptions: QueryOptions = QueryOptions()): Result<InvitesPage> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

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

/** Revokes [inviteId] on [chatId]. */
suspend fun revokeInvite(chatId: ChatId, inviteId: InviteId): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.revokeInvite(owner, chatId, inviteId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import com.flipcash.services.internal.network.extensions.asProtoSilenceState
import com.flipcash.services.internal.network.extensions.asProtoRules
import com.flipcash.services.internal.network.extensions.asQueryOptions
import com.flipcash.services.internal.network.extensions.asProtoKeyEnvelope
import com.flipcash.services.internal.network.extensions.asProtoInviteId
import com.flipcash.services.internal.network.extensions.asProtoInviteKeyEnvelope
import com.flipcash.services.internal.network.extensions.asProtoInviteParameters
import com.flipcash.services.internal.network.extensions.asProtoInviteSecretEnvelope
import com.flipcash.services.internal.network.extensions.asProtoInviteToken
import com.flipcash.services.internal.network.extensions.asTimestamp
import com.flipcash.services.internal.network.extensions.asUserId
import com.flipcash.services.internal.network.extensions.asUsername
import com.flipcash.services.internal.network.extensions.asViewMode
Expand All @@ -22,12 +28,18 @@ import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.DescriptionEdit
import com.flipcash.services.models.chat.EditChatParameters
import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.chat.InviteId
import com.flipcash.services.models.chat.InviteKeyEnvelope
import com.flipcash.services.models.chat.InviteParameters
import com.flipcash.services.models.chat.InviteSecretEnvelope
import com.flipcash.services.models.chat.InviteToken
import com.flipcash.services.models.chat.KeyEnvelope
import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.SilenceState
import com.flipcash.services.models.chat.StartChatParameters
import com.flipcash.services.models.chat.ViewMode
import com.getcode.utils.toByteString
import kotlin.time.Instant
import com.getcode.ed25519.Ed25519.KeyPair
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.internal.network.core.GrpcApi
Expand Down Expand Up @@ -168,15 +180,22 @@ internal class ChatApi @Inject constructor(
}
}

/**
* [owner] is optional when [inviteToken] is set: the token stands in for auth, so a viewer
* with an invite link can read the roster before joining. Without a token, [owner] is required.
*/
suspend fun getRoster(
owner: KeyPair,
owner: KeyPair?,
chatId: ChatId,
queryOptions: QueryOptions = QueryOptions(),
inviteToken: InviteToken? = null,
): RpcChatService.GetRosterResponse {
require(owner != null || inviteToken != null) { "getRoster needs an owner or an invite token" }
val request = RpcChatService.GetRosterRequest.newBuilder()
.setChatId(chatId.asChatId())
.setQueryOptions(queryOptions.asQueryOptions())
.apply { setAuth(authenticate(owner)) }
.apply { inviteToken?.let { setInviteToken(it.asProtoInviteToken()) } }
.apply { owner?.let { setAuth(authenticate(it)) } }
.build()

request.validate().orThrow()
Expand Down Expand Up @@ -312,9 +331,11 @@ internal class ChatApi @Inject constructor(
suspend fun joinChat(
owner: KeyPair,
chatId: ChatId,
invite: InviteParameters? = null,
): RpcChatService.JoinChatResponse {
val request = RpcChatService.JoinChatRequest.newBuilder()
.setChatId(chatId.asChatId())
.apply { invite?.let { setInvite(it.asProtoInviteParameters()) } }
.apply { setAuth(authenticate(owner)) }
.build()

Expand Down Expand Up @@ -588,4 +609,87 @@ internal class ChatApi @Inject constructor(
api.getKeyEnvelope(request)
}
}

/**
* Idempotent on [token]. [maxUses] 0 means unlimited; [expiresAt] null means it never expires.
* The envelopes are produced by invite crypto this layer does not do.
*/
suspend fun createInvite(
owner: KeyPair,
chatId: ChatId,
token: InviteToken,
keyEnvelope: InviteKeyEnvelope,
secretEnvelope: InviteSecretEnvelope,
maxUses: Int = 0,
expiresAt: Instant? = null,
): RpcChatService.CreateInviteResponse {
val request = RpcChatService.CreateInviteRequest.newBuilder()
.setChatId(chatId.asChatId())
.setInviteToken(token.asProtoInviteToken())
.setKeyEnvelope(keyEnvelope.asProtoInviteKeyEnvelope())
.setSecretEnvelope(secretEnvelope.asProtoInviteSecretEnvelope())
.setMaxUses(maxUses)
.apply { expiresAt?.let { setExpiresAt(it.asTimestamp()) } }
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

/** [owner] is optional: an anonymous viewer can look an invite up. */
suspend fun getInvite(
owner: KeyPair?,
token: InviteToken,
): RpcChatService.GetInviteResponse {
val request = RpcChatService.GetInviteRequest.newBuilder()
.setInviteToken(token.asProtoInviteToken())
.apply { owner?.let { setAuth(authenticate(it)) } }
.build()

request.validate().orThrow()

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

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

request.validate().orThrow()

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

suspend fun revokeInvite(
owner: KeyPair,
chatId: ChatId,
inviteId: InviteId,
): RpcChatService.RevokeInviteResponse {
val request = RpcChatService.RevokeInviteRequest.newBuilder()
.setChatId(chatId.asChatId())
.setInviteId(inviteId.asProtoInviteId())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

return withContext(Dispatchers.IO) {
api.revokeInvite(request)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.flipcash.services.internal.network.extensions

import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService
import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel
import com.flipcash.services.models.chat.Invite
import com.flipcash.services.models.chat.InviteId
import com.flipcash.services.models.chat.InviteKeyBundle
import com.flipcash.services.models.chat.InviteKeyEnvelope
import com.flipcash.services.models.chat.InviteParameters
import com.flipcash.services.models.chat.InviteSecretEnvelope
import com.flipcash.services.models.chat.InviteToken
import com.getcode.utils.toByteString
import kotlin.time.Instant

internal fun InviteId.asProtoInviteId(): ChatModel.InviteId =
ChatModel.InviteId.newBuilder().setValue(bytes.toByteString()).build()

internal fun InviteToken.asProtoInviteToken(): ChatModel.InviteToken =
ChatModel.InviteToken.newBuilder().setValue(bytes.toByteString()).build()

internal fun InviteKeyEnvelope.asProtoInviteKeyEnvelope(): ChatModel.InviteKeyEnvelope =
ChatModel.InviteKeyEnvelope.newBuilder()
.setSchemeValue(scheme)
.setNonce(nonce.toByteString())
.setCiphertext(ciphertext.toByteString())
.setSignature(signature.toByteString())
.build()

internal fun InviteSecretEnvelope.asProtoInviteSecretEnvelope(): ChatModel.InviteSecretEnvelope =
ChatModel.InviteSecretEnvelope.newBuilder()
.setSchemeValue(scheme)
.setNonce(nonce.toByteString())
.setCiphertext(ciphertext.toByteString())
.build()

internal fun InviteParameters.asProtoInviteParameters(): RpcChatService.JoinChatRequest.InviteParameters =
RpcChatService.JoinChatRequest.InviteParameters.newBuilder()
.setInviteToken(token.asProtoInviteToken())
.setSecretEnvelope(secretEnvelope.asProtoInviteSecretEnvelope())
.build()

internal fun ChatModel.InviteId.toInviteId(): InviteId = InviteId(value.toByteArray())

internal fun ChatModel.InviteKeyEnvelope.toInviteKeyEnvelope(): InviteKeyEnvelope = InviteKeyEnvelope(
scheme = schemeValue,
nonce = nonce.toByteArray(),
ciphertext = ciphertext.toByteArray(),
signature = signature.toByteArray(),
)

internal fun ChatModel.InviteSecretEnvelope.toInviteSecretEnvelope(): InviteSecretEnvelope = InviteSecretEnvelope(
scheme = schemeValue,
nonce = nonce.toByteArray(),
ciphertext = ciphertext.toByteArray(),
)

internal fun ChatModel.Invite.Status.toInviteStatus(): Invite.Status = when (this) {
ChatModel.Invite.Status.ACTIVE -> Invite.Status.ACTIVE
ChatModel.Invite.Status.EXHAUSTED -> Invite.Status.EXHAUSTED
ChatModel.Invite.Status.EXPIRED -> Invite.Status.EXPIRED
ChatModel.Invite.Status.REVOKED -> Invite.Status.REVOKED
else -> Invite.Status.UNKNOWN
}

internal fun ChatModel.Invite.toInvite(): Invite = Invite(
id = inviteId.toInviteId(),
chatId = chatId.toChatId(),
createdBy = createdBy.toId(),
maxUses = Integer.toUnsignedLong(maxUses),
useCount = Integer.toUnsignedLong(useCount),
createdAt = Instant.fromEpochSeconds(createdAt.seconds, createdAt.nanos),
expiresAt = if (hasExpiresAt()) Instant.fromEpochSeconds(expiresAt.seconds, expiresAt.nanos) else null,
status = status.toInviteStatus(),
secretEnvelope = if (hasSecretEnvelope()) secretEnvelope.toInviteSecretEnvelope() else null,
)

internal fun RpcChatService.GetKeyEnvelopeResponse.toInviteKeyBundle(): InviteKeyBundle = InviteKeyBundle(
inviteId = inviteKeyBundle.inviteId.toInviteId(),
keyEnvelope = inviteKeyBundle.keyEnvelope.toInviteKeyEnvelope(),
secretEnvelope = inviteKeyBundle.secretEnvelope.toInviteSecretEnvelope(),
wrappedBy = if (hasWrappedBy()) wrappedBy.toId() else null,
wrappedByPublicKey = if (hasWrappedByPublicKey()) wrappedByPublicKey.toPublicKey() else null,
)
Loading
Loading