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 @@ -71,7 +71,7 @@ internal val BalancePresets: List<Fiat> = 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
Expand Down
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.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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<Lobby> {
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<Unit> {
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<LobbyPage> {
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<Unit> {
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<Unit> {
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<Unit> {
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<StoredKeyEnvelope> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.getKeyEnvelope(owner, chatId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MediaItem> {
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<Unit> {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,22 @@ 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
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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Loading
Loading