From 18067b2b85ee392afdacbe54f00e4aff673e9819 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 23:18:04 -0400 Subject: [PATCH 1/3] feat(flipcash): scaffold flipcash2 0.23.0 contract in the service layer Add SendTestPush through api, service, repository and controller, EditChat rules with InvalidRules and RulesNotSatisfied errors, MetadataUpdate.RulesChanged, payment request updates on ChatUpdate, and RequestedPayment on sent/received activity metadata. Built against the local client override; the pin stays 0.22.0. --- .../services/controllers/PushController.kt | 6 ++ .../domain/ActivityFeedMessageMapper.kt | 8 +++ .../services/internal/network/api/ChatApi.kt | 1 + .../services/internal/network/api/PushApi.kt | 16 +++++ .../network/extensions/ProtobufToLocal.kt | 58 ++++++++++++++++--- .../internal/network/services/ChatService.kt | 2 + .../internal/network/services/PushService.kt | 18 ++++++ .../repositories/InternalPushRepository.kt | 4 ++ .../models/ActivityFeedNotification.kt | 15 +++++ .../com/flipcash/services/models/Errors.kt | 14 +++++ .../services/models/chat/ChatUpdate.kt | 5 ++ .../models/chat/EditChatParameters.kt | 4 ++ .../services/models/chat/MetadataUpdate.kt | 3 + .../paymentrequest/PaymentRequestUpdate.kt | 35 +++++++++++ .../services/repository/PushRepository.kt | 3 + 15 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestUpdate.kt diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PushController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PushController.kt index 2770a8b4f5..3cfa63bfe9 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PushController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PushController.kt @@ -18,6 +18,12 @@ class PushController @Inject constructor( return repository.addToken(owner, token, installationId) } + suspend fun sendTestPush(): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.sendTestPush(owner) + } + suspend fun deleteTokens(): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt index 3ffbdc8167..812e12d947 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt @@ -14,6 +14,7 @@ import com.flipcash.services.internal.network.extensions.toMint import com.flipcash.services.internal.network.extensions.toPublicKey import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.chat.Emoji import com.flipcash.services.internal.network.extensions.toChatId @@ -55,6 +56,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( Model.DirectlySentCryptoNotificationMetadata.DestinationIdentifierCase.USER_ID ) meta.userId.value.toByteArray().toList() else null, boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null, + requestedPayment = if (meta.hasRequestedPayment()) meta.requestedPayment.toDomain() else null, ) } Model.Notification.AdditionalMetadataCase.RECEIVED_CRYPTO -> { @@ -67,6 +69,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( Model.ReceivedCryptoNotificationMetadata.SourceIdentifierCase.USER_ID ) meta.userId.value.toByteArray().toList() else null, boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null, + requestedPayment = if (meta.hasRequestedPayment()) meta.requestedPayment.toDomain() else null, ) } Model.Notification.AdditionalMetadataCase.WITHDREW_CRYPTO -> NotificationMetadata.WithdrewCrypto( @@ -167,6 +170,11 @@ private fun Model.SwapState.toDomain(): SwapState = when (this) { Model.SwapState.UNRECOGNIZED, null -> SwapState.UNKNOWN } +private fun Model.RequestedPayment.toDomain(): RequestedPayment = RequestedPayment( + chatId = chatId.toChatId(), + paymentRequestId = paymentRequestId.value.toByteArray().toList(), +) + private fun Model.BoostedReaction.toDomain(): BoostedReaction = BoostedReaction( chatId = chatId.toChatId(), messageId = messageId.value, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index f571d10b58..2ec91ffa47 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -308,6 +308,7 @@ internal class ChatApi @Inject constructor( ) ) } + parameters.rules?.let { setRules(it.asProtoRules()) } parameters.coverPicture?.let { setCoverPicture( RpcChatService.EditChatRequest.CoverPicture.newBuilder() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt index 3ee2494b5b..bbafd0195f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt @@ -50,6 +50,22 @@ internal class PushApi @Inject constructor( } } + /** + * Sends a fixed test notification to every device the caller has registered. + */ + suspend fun sendTestPush(owner: KeyPair): PushService.SendTestPushResponse { + val request = + PushService.SendTestPushRequest.newBuilder() + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.sendTestPush(request) + } + } + /** * Removes all push tokens within an app install for a user */ diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index df78ae12c9..4f3e34ad18 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -52,6 +52,7 @@ import com.flipcash.services.models.chat.isSystemBlank import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MetadataUpdate +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.BanState import com.flipcash.services.models.chat.RestrictedUser @@ -458,6 +459,8 @@ internal fun ChatModel.MetadataUpdate.toMetadataUpdate( MetadataUpdate.PictureChanged(profilePictureChanged.newProfilePicture.toMediaItem()) ChatModel.MetadataUpdate.KindCase.COVER_PICTURE_CHANGED -> MetadataUpdate.CoverPictureChanged(coverPictureChanged.newCoverPicture.toMediaItem()) + ChatModel.MetadataUpdate.KindCase.RULES_CHANGED -> + MetadataUpdate.RulesChanged(rulesChanged.newRules.toChatRules()) else -> MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(0)) } } @@ -699,6 +702,9 @@ internal fun EventModel.ChatUpdate.toChatUpdate( lobbyUpdates = if (hasLobbyUpdates()) { lobbyUpdates.lobbyUpdatesList.mapNotNull { it.toLobbyUpdateOrNull() } } else emptyList(), + paymentRequestUpdates = if (hasPaymentRequestUpdates()) { + paymentRequestUpdates.paymentRequestUpdatesList.map { it.toPaymentRequestUpdate() } + } else emptyList(), ) } @@ -863,18 +869,56 @@ internal fun PaymentRequestModel.PaymentRequestMetadata.toPaymentRequest(): Paym id = paymentRequestId.value.toByteArray().toList(), creator = creator.toId(), createdAt = Instant.fromEpochSeconds(createdAt.seconds, createdAt.nanos), - status = when (status) { - PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_OPEN -> PaymentRequestStatus.Open - PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_COMPLETED -> PaymentRequestStatus.Completed - PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_CLOSED -> PaymentRequestStatus.Closed - PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_EXPIRED -> PaymentRequestStatus.Expired - else -> PaymentRequestStatus.Unknown - }, + status = status.toDomain(), closedAt = if (hasClosedAt()) Instant.fromEpochSeconds(closedAt.seconds, closedAt.nanos) else null, version = version, kind = groupChat.takeIf { hasGroupChat() }?.toKind() ?: PaymentRequest.Kind.Unsupported, ) +private fun PaymentRequestModel.PaymentRequestStatus.toDomain(): PaymentRequestStatus = when (this) { + PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_OPEN -> PaymentRequestStatus.Open + PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_COMPLETED -> PaymentRequestStatus.Completed + PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_CLOSED -> PaymentRequestStatus.Closed + PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_EXPIRED -> PaymentRequestStatus.Expired + else -> PaymentRequestStatus.Unknown +} + +internal fun PaymentRequestModel.PaymentRequestUpdate.toPaymentRequestUpdate(): PaymentRequestUpdate = + PaymentRequestUpdate( + paymentRequestId = paymentRequestId.value.toByteArray().toList(), + kind = when (kindCase) { + PaymentRequestModel.PaymentRequestUpdate.KindCase.CREATED -> + PaymentRequestUpdate.Kind.Created(created.toPaymentRequest()) + PaymentRequestModel.PaymentRequestUpdate.KindCase.UPDATES -> + PaymentRequestUpdate.Kind.Updates( + version = updates.version, + changes = updates.updatesList.map { it.toChange() }, + ) + else -> PaymentRequestUpdate.Kind.Unknown + }, + ) + +// An unknown update kind, or a share update with no share, maps to Unknown so the store refetches +// rather than applying a partial change. +private fun PaymentRequestModel.PaymentRequestUpdate.Update.toChange(): PaymentRequestUpdate.Change = + when (kindCase) { + PaymentRequestModel.PaymentRequestUpdate.Update.KindCase.SHARE_PAYMENT_UPDATE -> + sharePaymentUpdate.takeIf { it.hasShare() }?.share?.let { share -> + PaymentRequestUpdate.Change.SharePaid( + userId = share.userId.toId(), + paid = if (share.hasPaid()) share.paid.toPaidShare() else null, + ) + } ?: PaymentRequestUpdate.Change.Unknown + PaymentRequestModel.PaymentRequestUpdate.Update.KindCase.STATUS_UPDATE -> + PaymentRequestUpdate.Change.StatusChanged( + status = statusUpdate.status.toDomain(), + closedAt = if (statusUpdate.hasClosedAt()) { + Instant.fromEpochSeconds(statusUpdate.closedAt.seconds, statusUpdate.closedAt.nanos) + } else null, + ) + else -> PaymentRequestUpdate.Change.Unknown + } + private fun PaymentRequestModel.GroupChatPaymentRequest.toKind(): PaymentRequest.Kind { if (!hasSplit()) return PaymentRequest.Kind.Unsupported // An unknown currency can't be priced or shown; the request renders as unsupported. diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index dbbccd24d1..ba48b5fc73 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -316,6 +316,8 @@ internal class ChatService @Inject constructor( Result.failure(EditChatError.DescriptionModerated(response.flaggedCategory.toFlaggedCategory())) RpcChatService.EditChatResponse.Result.PROFILE_PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(EditChatError.PictureBlobNotAccepted()) RpcChatService.EditChatResponse.Result.COVER_PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(EditChatError.CoverPictureBlobNotAccepted()) + RpcChatService.EditChatResponse.Result.INVALID_RULES -> Result.failure(EditChatError.InvalidRules()) + RpcChatService.EditChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(EditChatError.RulesNotSatisfied()) RpcChatService.EditChatResponse.Result.UNRECOGNIZED -> Result.failure(EditChatError.Unrecognized()) else -> Result.failure(EditChatError.Other()) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PushService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PushService.kt index acefecced7..7a55d4fec4 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PushService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PushService.kt @@ -5,6 +5,7 @@ import com.getcode.opencode.utils.toValidationOrElse import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.flipcash.services.models.AddTokenError import com.flipcash.services.models.DeleteTokenError +import com.flipcash.services.models.SendTestPushError import com.getcode.ed25519.Ed25519.KeyPair import javax.inject.Inject import com.codeinc.flipcash.gen.push.v1.PushService as RpcPushService @@ -34,6 +35,23 @@ internal class PushService @Inject constructor( ) } + suspend fun sendTestPush(owner: KeyPair): Result { + return runCatching { + api.sendTestPush(owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcPushService.SendTestPushResponse.Result.OK -> Result.success(Unit) + RpcPushService.SendTestPushResponse.Result.UNRECOGNIZED -> Result.failure(SendTestPushError.Unrecognized()) + else -> Result.failure(SendTestPushError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { SendTestPushError.Other(cause = it) }) + } + ) + } + suspend fun deleteTokens( owner: KeyPair, installationId: String?, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPushRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPushRepository.kt index 8284b45dc0..2a80947c34 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPushRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPushRepository.kt @@ -15,6 +15,10 @@ internal class InternalPushRepository( ): Result = service.addToken(owner, token, installationId) .onFailure { ErrorUtils.handleError(it) } + override suspend fun sendTestPush(owner: Ed25519.KeyPair): Result = + service.sendTestPush(owner) + .onFailure { ErrorUtils.handleError(it) } + override suspend fun deleteTokens( owner: Ed25519.KeyPair, installationId: String? diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt index c1eaad6f41..a4378322fa 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt @@ -61,6 +61,8 @@ sealed interface NotificationMetadata { val phoneNumber: String? = null, val userId: ID? = null, val boostedReaction: BoostedReaction? = null, + // Mutually exclusive with [boostedReaction] (a oneof on the wire). + val requestedPayment: RequestedPayment? = null, ) : NotificationMetadata /** @@ -81,6 +83,8 @@ sealed interface NotificationMetadata { val phoneNumber: String? = null, val userId: ID? = null, val boostedReaction: BoostedReaction? = null, + // Mutually exclusive with [boostedReaction] (a oneof on the wire). + val requestedPayment: RequestedPayment? = null, ) : NotificationMetadata /** @@ -125,6 +129,17 @@ data class BoostedReaction( val emoji: Emoji, ) +/** + * Set on a sent/received payment notification when the payment paid a share of a payment request. + * On a directly-sent payment the payer paid their share to the request's creator; on a received + * one a debtor paid their share of the recipient's request. + */ +@Serializable +data class RequestedPayment( + val chatId: ChatId, + val paymentRequestId: ID, +) + enum class SwapState { UNKNOWN, PENDING, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index fa4642b171..7c305b767a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -64,6 +64,14 @@ sealed class DeleteTokenError( data class Other(override val cause: Throwable? = null) : DeleteTokenError(message = cause?.message, cause = cause), NotifiableError } +sealed class SendTestPushError( + override val message: String? = null, + override val cause: Throwable? = null +) : CodeServerError(message, cause) { + class Unrecognized : SendTestPushError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SendTestPushError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetActivityFeedMessagesError( override val message: String? = null, override val cause: Throwable? = null @@ -520,6 +528,12 @@ sealed class EditChatError( class DescriptionModerated(val category: ModerationResult.FlaggedCategory) : EditChatError("Description flagged: $category") class PictureBlobNotAccepted : EditChatError("Picture blob not accepted") class CoverPictureBlobNotAccepted : EditChatError("Cover picture blob not accepted") + // The replacement rules are malformed, or the chat is not a public group with a listener + // minimum balance. A client bug rather than something the user can fix, so notifiable. + class InvalidRules : EditChatError("Invalid rules"), NotifiableError + // The caller does not meet the rules they are setting. Recoverable by the user, so + // deliberately not a NotifiableError, matching StartChatError.RulesNotSatisfied. + class RulesNotSatisfied : EditChatError("Rules not satisfied") class Unrecognized : EditChatError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : EditChatError(message = cause?.message, cause = cause), NotifiableError } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt index 1a80b27ba0..812f34f6dd 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt @@ -1,5 +1,7 @@ package com.flipcash.services.models.chat +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate + data class ChatUpdate( val chatId: ChatId, val pointerUpdates: List = emptyList(), @@ -12,4 +14,7 @@ data class ChatUpdate( val rosterUpdates: List = emptyList(), // Creator-only, best-effort lobby changes for a private group. Unversioned; see LobbyUpdate. val lobbyUpdates: List = emptyList(), + // Best-effort real-time payment request changes; not part of the gap-detected event log. + // Apply per PaymentRequestUpdate's rules; see its docs. + val paymentRequestUpdates: List = emptyList(), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt index 530990999d..12ee57a0d8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt @@ -16,6 +16,10 @@ data class EditChatParameters( val coverPicture: BlobId? = null, // Null leaves the description unchanged; see [DescriptionEdit] for set vs clear. val description: DescriptionEdit? = null, + // The full replacement rules, not a patch. Only valid on a public group that has a listener + // minimum balance; the editable parts are that listener amount and the speaker minimum + // balance requirements. Null leaves the rules unchanged. + val rules: ChatRules? = null, ) /** diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt index 94e8d710ce..db33168347 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt @@ -18,4 +18,7 @@ sealed interface MetadataUpdate { // A group chat's cover picture was edited; replaces the cached cover. Best-effort delivery, // same caveat as TitleChanged. data class CoverPictureChanged(val newCoverPicture: MediaItem) : MetadataUpdate + // A group chat's rules were edited; replaces the cached rules wholesale. Best-effort + // delivery, same caveat as TitleChanged. + data class RulesChanged(val newRules: ChatRules) : MetadataUpdate } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestUpdate.kt new file mode 100644 index 0000000000..342c1b65b3 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestUpdate.kt @@ -0,0 +1,35 @@ +package com.flipcash.services.models.paymentrequest + +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +/** + * A best-effort real-time change to a payment request, from `ChatUpdate.payment_request_updates`. + * Unlike chat events these are not gap-detected, so the store applies them by version: + * + * - [Kind.Created]: treat like a fetched copy and keep the greater version (see [newerOf]). + * - [Kind.Updates] at version V against a held version L: V <= L is ignored; V == L + 1 applies the + * [Kind.Updates.changes] in order and sets the version to V; V > L + 1 refetches the request. + * - A [Change.Unknown] anywhere in a batch, or [Kind.Unknown], refetches rather than guessing. + */ +data class PaymentRequestUpdate( + val paymentRequestId: ID, + val kind: Kind, +) { + sealed interface Kind { + data class Created(val paymentRequest: PaymentRequest) : Kind + data class Updates(val version: Long, val changes: List) : Kind + data object Unknown : Kind + } + + sealed interface Change { + /** [userId]'s share is now [paid]; apply it onto the held share of that user. */ + data class SharePaid(val userId: ID, val paid: PaidShare?) : Change + + /** The request moved to [status] (COMPLETED or CLOSED on the wire), closed at [closedAt]. */ + data class StatusChanged(val status: PaymentRequestStatus, val closedAt: Instant?) : Change + + /** A change kind this build does not know, or a share update with no share. */ + data object Unknown : Change + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PushRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PushRepository.kt index 229949d355..2d8ce3725b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PushRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PushRepository.kt @@ -9,6 +9,9 @@ interface PushRepository { installationId: String? ): Result + /** Sends a fixed test notification to every device the caller has registered. */ + suspend fun sendTestPush(owner: KeyPair): Result + suspend fun deleteTokens( owner: KeyPair, installationId: String?, From b2f36936b75cf1eaa090195462783cf7477517ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 23:18:04 -0400 Subject: [PATCH 2/3] feat(chat): apply RulesChanged metadata updates to the cached chat Persist the full replacement rules from the event stream. Payment request updates are surfaced on ChatUpdate but not yet applied (TODO). --- .../chat/internal/delegates/EventStreamDelegate.kt | 9 +++++++++ .../com/flipcash/app/persistence/dao/ChatMetadataDao.kt | 7 +++++++ .../app/persistence/sources/ChatMetadataDataSource.kt | 6 ++++++ .../persistence/sources/mapper/chat/ChatEntityMapper.kt | 2 ++ 4 files changed, 24 insertions(+) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index b871482a11..815d330c68 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -451,6 +451,11 @@ class EventStreamDelegate @Inject constructor( // Same best-effort contract as TitleChanged above. Replaces the cached cover. metadataDataSource.updateCoverPicture(chatId, metaUpdate.newCoverPicture) } + is MetadataUpdate.RulesChanged -> { + // Same best-effort contract as TitleChanged above. The wire carries the full + // replacement rules, so they overwrite the cached ones. + metadataDataSource.updateRules(chatId, metaUpdate.newRules) + } } } @@ -471,6 +476,10 @@ class EventStreamDelegate @Inject constructor( // --- Lobby changes --- + // TODO(flipcash2 0.23.0): ChatUpdate.paymentRequestUpdates arrive here but nothing applies + // them yet. Apply by version per PaymentRequestUpdate's docs (created: keep greater; + // updates: ignore <= held, apply in order at held + 1, refetch beyond or on an unknown + // change) once a payment request store consumes the stream. if (update.lobbyUpdates.isNotEmpty()) { _events.send(Event.LobbyChanged(chatId, update.lobbyUpdates)) } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index b050d9356d..683efc17d2 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -341,6 +341,13 @@ interface ChatMetadataDao { @Query("UPDATE chat_metadata SET cover_picture_json = :coverPictureJson WHERE chat_id_hex = :chatIdHex") suspend fun updateCoverPicture(chatIdHex: String, coverPictureJson: MediaItem) + /** + * Unconditional, for the same reason as [updateTitle]: `MetadataUpdate.RulesChanged` carries no + * version. The wire carries the full replacement rules, so this overwrites the column. + */ + @Query("UPDATE chat_metadata SET rules_json = :rulesJson WHERE chat_id_hex = :chatIdHex") + suspend fun updateRules(chatIdHex: String, rulesJson: ChatRulesSerialized?) + @Query("DELETE FROM chat_metadata") suspend fun deleteAll() } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 2db45e47a3..7068d70594 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.ViewerState @@ -214,6 +215,11 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.updateCoverPicture(mapper.chatIdHex(chatId), coverPicture) } + /** Applies a `MetadataUpdate.RulesChanged` for [chatId]. See ChatMetadataDao.updateRules. */ + suspend fun updateRules(chatId: ChatId, rules: ChatRules) { + db?.chatMetadataDao()?.updateRules(mapper.chatIdHex(chatId), mapper.rulesSerialized(rules)) + } + suspend fun getAnalyticsCountedThrough(chatId: ChatId): Long = db?.chatMetadataDao()?.getAnalyticsCountedThrough(mapper.chatIdHex(chatId)) ?: 0L diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 6fe287176f..4f883736da 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -313,6 +313,8 @@ class ChatEntityMapper @Inject constructor() { // region Helpers + fun rulesSerialized(rules: ChatRules): ChatRulesSerialized = rules.toSerialized() + fun chatIdHex(chatId: ChatId): String = chatId.bytes.toList().hexEncodedString() fun chatIdFromHex(hex: String): ChatId = ChatId(hex.hexToByteArray()) From 92946df305e8e5b9619222620b7a8404370d6c9b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 23:53:10 -0400 Subject: [PATCH 3/3] chore(deps): bump flipcash2-client-protocol to 0.23.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27ae2741f2..1bd4cf1804 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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.22.0" +flipcash2-client-protocol = "0.23.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,