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 @@ -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)
}
}
}

Expand All @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
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.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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class PushController @Inject constructor(
return repository.addToken(owner, token, installationId)
}

suspend fun sendTestPush(): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.sendTestPush(owner)
}

suspend fun deleteTokens(): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ internal class ChatApi @Inject constructor(
)
)
}
parameters.rules?.let { setRules(it.asProtoRules()) }
parameters.coverPicture?.let {
setCoverPicture(
RpcChatService.EditChatRequest.CoverPicture.newBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -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(),
)
}

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,6 +35,23 @@ internal class PushService @Inject constructor(
)
}

suspend fun sendTestPush(owner: KeyPair): Result<Unit> {
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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ internal class InternalPushRepository(
): Result<Unit> = service.addToken(owner, token, installationId)
.onFailure { ErrorUtils.handleError(it) }

override suspend fun sendTestPush(owner: Ed25519.KeyPair): Result<Unit> =
service.sendTestPush(owner)
.onFailure { ErrorUtils.handleError(it) }

override suspend fun deleteTokens(
owner: Ed25519.KeyPair,
installationId: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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

/**
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MessagePointer> = emptyList(),
Expand All @@ -12,4 +14,7 @@ data class ChatUpdate(
val rosterUpdates: List<RosterChange> = emptyList(),
// Creator-only, best-effort lobby changes for a private group. Unversioned; see LobbyUpdate.
val lobbyUpdates: List<LobbyUpdate> = 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<PaymentRequestUpdate> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

/**
Expand Down
Loading
Loading