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
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ protovalidate-kt = "0.1.3"
# cadence and there is nothing to keep aligned.
# 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.18.0"
ocp-client-protocol = "0.7.0"
flipcash2-client-protocol = "0.19.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
@@ -0,0 +1,40 @@
package com.flipcash.services.controllers

import com.codeinc.flipcash.gen.poll.v1.Model
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.repository.PollRepository
import com.flipcash.services.user.UserManager
import javax.inject.Inject

class PollController @Inject constructor(
private val repository: PollRepository,
private val userManager: UserManager,
) {
suspend fun createPoll(
chatId: ChatId,
definition: Model.PollDefinition,
idempotencyKey: Model.IdempotencyKey,
): Result<Model.PollMetadata> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.createPoll(owner, chatId, definition, idempotencyKey)
}

suspend fun getPoll(pollId: Model.PollId): Result<Model.PollMetadata> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.getPoll(owner, pollId)
}

suspend fun getPolls(pollIds: List<Model.PollId>): Result<List<Model.PollMetadata>> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.getPolls(owner, pollIds)
}

suspend fun vote(pollId: Model.PollId, optionIndices: List<Int>): Result<Model.PollState> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.vote(owner, pollId, optionIndices)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import com.flipcash.services.internal.network.services.ChatMessagingService
import com.flipcash.services.internal.network.services.EmailVerificationService
import com.flipcash.services.internal.network.services.ContactListService
import com.flipcash.services.internal.network.services.ModerationService
import com.flipcash.services.internal.network.services.PollService
import com.flipcash.services.internal.repositories.InternalPollRepository
import com.flipcash.services.repository.PollRepository
import com.flipcash.services.internal.network.services.PhoneVerificationService
import com.flipcash.services.internal.network.services.ProfileService
import com.flipcash.services.internal.network.services.PurchaseService
Expand Down Expand Up @@ -255,6 +258,11 @@ internal object FlipcashModule {
): ProfileRepository =
InternalProfileRepository(service, userProfileMapper, socialAccountMapper)

@Provides
internal fun providesPollRepository(
service: PollService,
): PollRepository = InternalPollRepository(service)

@Provides
internal fun providesModerationRepository(
service: ModerationService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.flipcash.services.internal.network.api

import com.codeinc.flipcash.gen.poll.v1.Model
import com.codeinc.flipcash.gen.poll.v1.PollGrpcKt
import com.codeinc.flipcash.gen.poll.v1.PollService
import com.codeinc.flipcash.gen.poll.v1.validate
import com.flipcash.services.internal.annotations.FlipcashManagedChannel
import com.flipcash.services.internal.network.extensions.asChatId
import com.flipcash.services.internal.network.extensions.authenticate
import com.flipcash.services.models.chat.ChatId
import com.getcode.ed25519.Ed25519
import com.getcode.opencode.internal.network.core.GrpcApi
import dev.bmcreations.protovalidate.orThrow
import io.grpc.ManagedChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
internal class PollApi @Inject constructor(
@FlipcashManagedChannel
managedChannel: ManagedChannel,
) : GrpcApi(managedChannel) {
private val api = PollGrpcKt.PollCoroutineStub(managedChannel)
.withWaitForReady()

suspend fun createPoll(
owner: Ed25519.KeyPair,
chatId: ChatId,
definition: Model.PollDefinition,
idempotencyKey: Model.IdempotencyKey,
): PollService.CreatePollResponse {
val request = PollService.CreatePollRequest.newBuilder()
.setChatId(chatId.asChatId())
.setDefinition(definition)
.setIdempotencyKey(idempotencyKey)
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

suspend fun getPoll(owner: Ed25519.KeyPair, pollId: Model.PollId): PollService.GetPollResponse {
val request = PollService.GetPollRequest.newBuilder()
.setPollId(pollId)
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

suspend fun getPolls(owner: Ed25519.KeyPair, pollIds: List<Model.PollId>): PollService.GetPollsResponse {
val request = PollService.GetPollsRequest.newBuilder()
.addAllPollIds(pollIds)
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

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

suspend fun vote(
owner: Ed25519.KeyPair,
pollId: Model.PollId,
optionIndices: List<Int>,
): PollService.VoteResponse {
val request = PollService.VoteRequest.newBuilder()
.setPollId(pollId)
.addAllOptionIndices(optionIndices)
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

return withContext(Dispatchers.IO) {
api.vote(request)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent {
MessagingModel.CashContent.Verb.TIPPED -> MessageContent.Cash.Action.TIPPED
else -> MessageContent.Cash.Action.SENT
},
receiver = if (cash.hasReceiver()) cash.receiver.value.toByteArray().toList() else null,
)
MessagingModel.Content.TypeCase.REPLY -> MessageContent.Reply(
repliedMessageId = reply.repliedMessageId.value,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.flipcash.services.internal.network.services

import com.codeinc.flipcash.gen.poll.v1.Model
import com.codeinc.flipcash.gen.poll.v1.PollService
import com.flipcash.services.internal.network.api.PollApi
import com.flipcash.services.internal.network.extensions.toFlaggedCategory
import com.flipcash.services.models.CreatePollError
import com.flipcash.services.models.GetPollError
import com.flipcash.services.models.GetPollsError
import com.flipcash.services.models.VoteError
import com.flipcash.services.models.chat.ChatId
import com.getcode.ed25519.Ed25519
import com.getcode.opencode.internal.network.extensions.foldWithSuppression
import com.getcode.opencode.utils.toValidationOrElse
import javax.inject.Inject

internal class PollService @Inject constructor(
private val api: PollApi,
) {
suspend fun createPoll(
owner: Ed25519.KeyPair,
chatId: ChatId,
definition: Model.PollDefinition,
idempotencyKey: Model.IdempotencyKey,
): Result<Model.PollMetadata> {
return runCatching {
api.createPoll(owner, chatId, definition, idempotencyKey)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
PollService.CreatePollResponse.Result.OK -> Result.success(response.poll)
PollService.CreatePollResponse.Result.DENIED -> Result.failure(CreatePollError.Denied())
PollService.CreatePollResponse.Result.CHAT_NOT_SUPPORTED -> Result.failure(CreatePollError.ChatNotSupported())
PollService.CreatePollResponse.Result.ENCRYPTION_NOT_ALLOWED -> Result.failure(CreatePollError.EncryptionNotAllowed())
PollService.CreatePollResponse.Result.ENCRYPTION_REQUIRED -> Result.failure(CreatePollError.EncryptionRequired())
PollService.CreatePollResponse.Result.OPTION_COUNT_MISMATCH -> Result.failure(CreatePollError.OptionCountMismatch())
PollService.CreatePollResponse.Result.INVALID_ENDS_AT -> Result.failure(CreatePollError.InvalidEndsAt())
PollService.CreatePollResponse.Result.INVALID_BALANCE_PARAMETERS -> Result.failure(CreatePollError.InvalidBalanceParameters())
PollService.CreatePollResponse.Result.TEXT_MODERATED -> Result.failure(CreatePollError.TextModerated(response.flaggedCategory.toFlaggedCategory()))
PollService.CreatePollResponse.Result.UNRECOGNIZED -> Result.failure(CreatePollError.Unrecognized())
}
},
onFailure = { cause ->
Result.failure(cause.toValidationOrElse { CreatePollError.Other(cause = it) })
}
)
}

suspend fun getPoll(owner: Ed25519.KeyPair, pollId: Model.PollId): Result<Model.PollMetadata> {
return runCatching {
api.getPoll(owner, pollId)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
PollService.GetPollResponse.Result.OK -> Result.success(response.poll)
PollService.GetPollResponse.Result.DENIED -> Result.failure(GetPollError.Denied())
PollService.GetPollResponse.Result.NOT_FOUND -> Result.failure(GetPollError.NotFound())
PollService.GetPollResponse.Result.UNRECOGNIZED -> Result.failure(GetPollError.Unrecognized())
}
},
onFailure = { cause ->
Result.failure(cause.toValidationOrElse { GetPollError.Other(cause = it) })
}
)
}

suspend fun getPolls(owner: Ed25519.KeyPair, pollIds: List<Model.PollId>): Result<List<Model.PollMetadata>> {
return runCatching {
api.getPolls(owner, pollIds)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
PollService.GetPollsResponse.Result.OK -> Result.success(response.pollsList)
PollService.GetPollsResponse.Result.DENIED -> Result.failure(GetPollsError.Denied())
PollService.GetPollsResponse.Result.UNRECOGNIZED -> Result.failure(GetPollsError.Unrecognized())
}
},
onFailure = { cause ->
Result.failure(cause.toValidationOrElse { GetPollsError.Other(cause = it) })
}
)
}

suspend fun vote(
owner: Ed25519.KeyPair,
pollId: Model.PollId,
optionIndices: List<Int>,
): Result<Model.PollState> {
return runCatching {
api.vote(owner, pollId, optionIndices)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
PollService.VoteResponse.Result.OK -> Result.success(response.state)
PollService.VoteResponse.Result.DENIED -> Result.failure(VoteError.Denied())
PollService.VoteResponse.Result.NOT_FOUND -> Result.failure(VoteError.NotFound())
PollService.VoteResponse.Result.POLL_CLOSED -> Result.failure(VoteError.PollClosed())
PollService.VoteResponse.Result.INVALID_SELECTION -> Result.failure(VoteError.InvalidSelection())
PollService.VoteResponse.Result.UNRECOGNIZED -> Result.failure(VoteError.Unrecognized())
}
},
onFailure = { cause ->
Result.failure(cause.toValidationOrElse { VoteError.Other(cause = it) })
}
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.flipcash.services.internal.repositories

import com.codeinc.flipcash.gen.poll.v1.Model
import com.flipcash.services.internal.network.services.PollService
import com.flipcash.services.models.CreatePollError
import com.flipcash.services.models.GetPollError
import com.flipcash.services.models.VoteError
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.repository.PollRepository
import com.getcode.ed25519.Ed25519.KeyPair
import com.getcode.utils.ErrorUtils

internal class InternalPollRepository(
private val service: PollService,
) : PollRepository {
override suspend fun createPoll(
owner: KeyPair,
chatId: ChatId,
definition: Model.PollDefinition,
idempotencyKey: Model.IdempotencyKey,
): Result<Model.PollMetadata> {
return service.createPoll(owner, chatId, definition, idempotencyKey)
.onFailure {
if (it !is CreatePollError.Denied && it !is CreatePollError.TextModerated) ErrorUtils.handleError(it)
}
}

override suspend fun getPoll(owner: KeyPair, pollId: Model.PollId): Result<Model.PollMetadata> {
return service.getPoll(owner, pollId)
.onFailure { if (it !is GetPollError.NotFound) ErrorUtils.handleError(it) }
}

override suspend fun getPolls(owner: KeyPair, pollIds: List<Model.PollId>): Result<List<Model.PollMetadata>> {
return service.getPolls(owner, pollIds)
.onFailure { ErrorUtils.handleError(it) }
}

override suspend fun vote(owner: KeyPair, pollId: Model.PollId, optionIndices: List<Int>): Result<Model.PollState> {
return service.vote(owner, pollId, optionIndices)
.onFailure {
if (it !is VoteError.PollClosed && it !is VoteError.NotFound) ErrorUtils.handleError(it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ fun buildTipDmPaymentMetadata(
.setAction(
when (action) {
TipAction.SEND ->
FlipcashIntentModel.ChatMetadata.DmPayment.Action.SEND
FlipcashIntentModel.PaymentAction.SEND
TipAction.TIP ->
FlipcashIntentModel.ChatMetadata.DmPayment.Action.TIP
FlipcashIntentModel.PaymentAction.TIP
}
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -905,3 +905,50 @@ class BlobRejectedException(val rejection: BlobRejection) :

// Thrown when a blob did not reach READY within the client's polling window.
class BlobNotReadyException : Exception("Blob did not become ready in time")

sealed class CreatePollError(
override val message: String? = null,
override val cause: Throwable? = null
): CodeServerError(message, cause) {
class Denied : CreatePollError("Denied")
class ChatNotSupported : CreatePollError("Chat does not support polls")
class EncryptionNotAllowed : CreatePollError("Encryption not allowed")
class EncryptionRequired : CreatePollError("Encryption required")
class OptionCountMismatch : CreatePollError("Option count mismatch")
class InvalidEndsAt : CreatePollError("Invalid end time")
class InvalidBalanceParameters : CreatePollError("Invalid balance parameters")
class TextModerated(val category: ModerationResult.FlaggedCategory) : CreatePollError("Poll text flagged: $category")
class Unrecognized : CreatePollError("Unrecognized"), NotifiableError
data class Other(override val cause: Throwable? = null) : CreatePollError(message = cause?.message, cause = cause), NotifiableError
}

sealed class GetPollError(
override val message: String? = null,
override val cause: Throwable? = null
): CodeServerError(message, cause) {
class Denied : GetPollError("Denied")
class NotFound : GetPollError("Not found")
class Unrecognized : GetPollError("Unrecognized"), NotifiableError
data class Other(override val cause: Throwable? = null) : GetPollError(message = cause?.message, cause = cause), NotifiableError
}

sealed class GetPollsError(
override val message: String? = null,
override val cause: Throwable? = null
): CodeServerError(message, cause) {
class Denied : GetPollsError("Denied")
class Unrecognized : GetPollsError("Unrecognized"), NotifiableError
data class Other(override val cause: Throwable? = null) : GetPollsError(message = cause?.message, cause = cause), NotifiableError
}

sealed class VoteError(
override val message: String? = null,
override val cause: Throwable? = null
): CodeServerError(message, cause) {
class Denied : VoteError("Denied")
class NotFound : VoteError("Not found")
class PollClosed : VoteError("Poll closed")
class InvalidSelection : VoteError("Invalid selection")
class Unrecognized : VoteError("Unrecognized"), NotifiableError
data class Other(override val cause: Throwable? = null) : VoteError(message = cause?.message, cause = cause), NotifiableError
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ sealed interface MessageContent {
val tokenName: String = "",
val tokenImageUrl: String = "",
val action: Action = Action.SENT,
/** The group-payment recipient (`CashContent.receiver`); null for DM cash and older messages. */
val receiver: ID? = null,
) : MessageContent {
enum class Action {
SENT,
Expand Down
Loading
Loading