From f7aab02fd33d4e7aa6f6da26f21785f1f9b96e0e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 11:36:33 -0400 Subject: [PATCH 1/8] feat(chat): map ENCRYPTION_NOT_ALLOWED send/edit results SendMessageResponse and EditMessageResponse gain a result case for EncryptedContent sent outside a DM. Map both to a new SendMessageError.EncryptionNotAllowed / EditMessageError.EncryptionNotAllowed alongside the existing Denied/Conflict cases; the client doesn't construct EncryptedContent yet, so this is defensive. --- .../internal/network/services/ChatMessagingService.kt | 2 ++ .../src/main/kotlin/com/flipcash/services/models/Errors.kt | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt index 0b07b0459d..0f2386d4f4 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt @@ -127,6 +127,7 @@ internal class ChatMessagingService @Inject constructor( when (response.result) { RpcMessagingService.SendMessageResponse.Result.OK -> Result.success(response.message) RpcMessagingService.SendMessageResponse.Result.DENIED -> Result.failure(SendMessageError.Denied()) + RpcMessagingService.SendMessageResponse.Result.ENCRYPTION_NOT_ALLOWED -> Result.failure(SendMessageError.EncryptionNotAllowed()) RpcMessagingService.SendMessageResponse.Result.UNRECOGNIZED -> Result.failure(SendMessageError.Unrecognized()) else -> Result.failure(SendMessageError.Other()) } @@ -223,6 +224,7 @@ internal class ChatMessagingService @Inject constructor( RpcMessagingService.EditMessageResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(EditMessageError.MessageNotFound()) RpcMessagingService.EditMessageResponse.Result.CANNOT_EDIT -> Result.failure(EditMessageError.CannotEdit()) RpcMessagingService.EditMessageResponse.Result.CONFLICT -> Result.failure(EditMessageError.Conflict()) + RpcMessagingService.EditMessageResponse.Result.ENCRYPTION_NOT_ALLOWED -> Result.failure(EditMessageError.EncryptionNotAllowed()) RpcMessagingService.EditMessageResponse.Result.UNRECOGNIZED -> Result.failure(EditMessageError.Unrecognized()) else -> Result.failure(EditMessageError.Other()) } 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 0145d77e90..9d4e2df4cd 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 @@ -464,6 +464,9 @@ sealed class SendMessageError( override val cause: Throwable? = null ): CodeServerError(message, cause) { class Denied : SendMessageError("Denied") + // Sender attempted to send EncryptedContent to a chat that doesn't allow it (non-DM). + // The client never constructs EncryptedContent today, so this is defensive. + class EncryptionNotAllowed : SendMessageError("Encryption not allowed") class Unrecognized : SendMessageError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : SendMessageError(message = cause?.message, cause = cause), NotifiableError } @@ -525,6 +528,9 @@ sealed class EditMessageError( class MessageNotFound : EditMessageError("Message not found") class CannotEdit : EditMessageError("Cannot edit") class Conflict : EditMessageError("Conflict") + // Editor attempted to send EncryptedContent to a chat that doesn't allow it (non-DM). + // The client never constructs EncryptedContent today, so this is defensive. + class EncryptionNotAllowed : EditMessageError("Encryption not allowed") class Unrecognized : EditMessageError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : EditMessageError(message = cause?.message, cause = cause), NotifiableError } From df0d1d2b12a6e9d5b05ee443b982d4bbf08c3e2c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 11:36:33 -0400 Subject: [PATCH 2/8] feat(chat): add creator and useE2ee to ChatMetadata Mirrors chat/v1 Metadata.creator (group chats) and Metadata.use_e2ee (DMs, transitional). useE2ee is carried through but ignored behaviourally until E2EE send is implemented. --- .../flipcash/services/internal/domain/ChatMetadataMapper.kt | 3 +++ .../com/flipcash/services/models/chat/ChatMetadata.kt | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index 7732551631..a789d7beae 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -50,6 +50,9 @@ class ChatMetadataMapper @Inject constructor( rosterSummary = from.rosterSummary.toRosterSummary(), rules = if (from.hasRules()) from.rules.toChatRules() else null, viewerState = if (from.hasViewerState()) from.viewerState.toViewerState() else null, + creator = if (from.hasCreator()) from.creator.toId() else null, + // Transitional flag (see chat/v1 model.proto doc); ignored behaviourally for now. + useE2ee = from.useE2Ee, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index 1f7be96234..b39e60cd69 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -1,5 +1,6 @@ package com.flipcash.services.models.chat +import com.getcode.opencode.model.core.ID import kotlin.time.Instant data class ChatMetadata( @@ -22,4 +23,9 @@ data class ChatMetadata( val rules: ChatRules? = null, // Per-viewer chat state, absent when the chat holds nothing about the viewer. val viewerState: ViewerState? = null, + // Group creator; null for DMs and for group chats reconstructed without a server round trip. + val creator: ID? = null, + // Transitional E2EE flag (DMs only): true means clients should send new content as + // EncryptedContent. Ignored behaviourally for now -- see chat/v1 model.proto. + val useE2ee: Boolean = false, ) From 43dc96f629c8f02388db9f58e58543847e7be2a7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 11:37:32 -0400 Subject: [PATCH 3/8] feat(chat): map Content.encrypted to an unsupported-message bubble messaging/v1 Content gains an encrypted case. Decoding it needs E2EE crypto (X25519/HKDF/XChaCha20), which is a cross-platform parity hotspot with no decision yet, so it isn't decoded here. Add MessageContent.Encrypted through the domain and persistence mapping and render it as a tombstone bubble, the same treatment as a deleted message. Sending EncryptedContent stays unimplemented and fails fast. --- apps/flipcash/core/src/main/res/values/strings.xml | 1 + .../com/flipcash/shared/chat/models/ChatListItem.kt | 1 + .../flipcash/shared/chat/ui/ChatSummaryMapping.kt | 1 + .../com/flipcash/shared/chat/ui/LinkDetection.kt | 1 + .../com/flipcash/shared/chat/ui/MessageBubble.kt | 13 +++++++++++++ .../persistence/converters/ChatTypeConverters.kt | 6 +++++- .../sources/mapper/chat/ChatEntityMapper.kt | 2 ++ .../internal/network/extensions/LocalToProtobuf.kt | 4 ++++ .../internal/network/extensions/ProtobufToLocal.kt | 11 +++++++++++ .../flipcash/services/models/chat/MessageContent.kt | 4 ++++ 10 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c981d0eff9..a86fb14294 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -974,6 +974,7 @@ Edited You deleted this message This message was deleted + This message isn\'t supported on this version Cash diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index dc8bef481d..93d45af2b5 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -164,6 +164,7 @@ sealed interface ChatListItem { is MessageContent.Media -> "media" is MessageContent.Reply -> "reply-message" is MessageContent.System -> "system-message" + is MessageContent.Encrypted -> "encrypted-message" } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 3c0d5ebcd0..06dd020cb3 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -147,6 +147,7 @@ private fun MessageContent.previewText( // reaches here when every message in the chat is deleted — and then there is nothing // to preview. is MessageContent.Deleted -> null + is MessageContent.Encrypted -> null // TODO: is MessageContent.Media -> null diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt index c47a79d5e9..665f044f0e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt @@ -83,5 +83,6 @@ fun MessageContent.linkableText(): String? = when (this) { is MessageContent.Deleted, is MessageContent.Media, is MessageContent.System, + is MessageContent.Encrypted, -> null } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 7f3e21153d..6e98bdc074 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -112,6 +112,7 @@ fun ContentBubble( is MessageContent.Media -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Reply -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.System -> maxWidth + is MessageContent.Encrypted -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION } // A split message's citation goes on its first row and its marker on its last, so the @@ -218,6 +219,18 @@ fun ContentBubble( attention = attention, ) + // Not decoded client-side -- see MessageContent.Encrypted. Rendered as a + // tombstone-style text bubble, the same treatment as a deleted message. + is MessageContent.Encrypted -> TextBubble( + modifier = modifier, + text = stringResource(R.string.label_messageUnsupported), + isFromSelf = item.isFromSelf, + position = position, + maxWidth = bubbleMaxWidth, + isTombstone = true, + attention = attention, + ) + // TODO is MessageContent.Media -> Unit is MessageContent.System -> Unit diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index adae839637..d2c205a3fe 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -191,7 +191,11 @@ sealed interface MessageContentSerialized { @SerialName("system") data class System(val fallbackText: String) : MessageContentSerialized - + // Placeholder for MessageContent.Encrypted -- see that type's doc. Nothing to persist + // beyond the marker; the content itself is never decoded client-side. + @Serializable + @SerialName("encrypted") + data object Encrypted : MessageContentSerialized } @Serializable 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 f2b3bb2f10..8d45c1bbbe 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 @@ -328,6 +328,7 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this deletedAt = deletedTs.epochSeconds, deletedBy = deletedBy?.hexEncodedString(), ) + is MessageContent.Encrypted -> MessageContentSerialized.Encrypted } private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { @@ -357,6 +358,7 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { deletedTs = Instant.fromEpochSeconds(deletedAt), deletedBy = deletedBy?.hexToIdExt(), ) + is MessageContentSerialized.Encrypted -> MessageContent.Encrypted } private fun MessagePointer.toSerialized(): MessagePointerSerialized = MessagePointerSerialized( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index 4fa4217cb3..b2690fec63 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -171,6 +171,10 @@ internal fun MessageContent.asContent(): MessagingModel.Content { .setDeleted(deletedBuilder) .build() } + // The client never constructs this locally -- it only exists as a decode result for + // incoming `Content.encrypted` (see MessageContent.Encrypted). Sending EncryptedContent + // needs its own crypto implementation (X25519/HKDF/XChaCha20), tracked separately. + is MessageContent.Encrypted -> error("MessageContent.Encrypted cannot be sent; encryption is not implemented") } } 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 73353bca7a..5f743a8c0d 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 @@ -117,6 +117,11 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { sendingUserId = if (chatMetadata.hasSendingUserId()) chatMetadata.sendingUserId.toId() else null, chatType = chatMetadata.type.toChatType(), message = if (chatMetadata.hasMessage()) chatMetadata.message.toChatMessage() else null, + // TODO(push/v1 ChatMetadata.message_ref): a long message now arrives as + // `message_id` only (fetch via Messaging.GetMessage(chatId, messageId)). Carried + // here for a future fetch; today the message-less case still falls back to + // PushHandlingPlanner's existing LoadMessages sync, so nothing is dropped. + messageId = if (chatMetadata.hasMessageId()) chatMetadata.messageId.value else null, muted = chatMetadata.muted, ) } else null @@ -189,6 +194,9 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { deletedTs = Instant.fromEpochSeconds(deleted.deletedTs.seconds, deleted.deletedTs.nanos), deletedBy = if (deleted.hasDeletedBy()) deleted.deletedBy.toId() else null, ) + // Not decoded: E2EE crypto (X25519/HKDF/XChaCha20) is a cross-platform parity hotspot + // that needs its own decision. Rendered as unsupported rather than dropped. + MessagingModel.Content.TypeCase.ENCRYPTED -> MessageContent.Encrypted else -> MessageContent.Text("") } } @@ -399,6 +407,9 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { rosterSummary = rosterSummary.toRosterSummary(), rules = if (hasRules()) rules.toChatRules() else null, viewerState = if (hasViewerState()) viewerState.toViewerState() else null, + creator = if (hasCreator()) creator.toId() else null, + // Transitional flag (see chat/v1 model.proto doc); ignored behaviourally for now. + useE2ee = useE2Ee, ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 5cc174789e..6583d62e7b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -33,4 +33,8 @@ sealed interface MessageContent { val deletedTs: Instant, val deletedBy: ID?, ) : MessageContent + // Placeholder for `messaging.v1.Content.encrypted` (DMs only). Crypto (X25519/HKDF/ + // XChaCha20) is a cross-platform parity hotspot and needs its own decision; until then this + // renders as an unsupported message rather than being decoded. + data object Encrypted : MessageContent } From 9ae7d06c9b3ba6a411f1b088ae616c246437ac79 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 11:37:33 -0400 Subject: [PATCH 4/8] fix(push): carry ChatMetadata.message_id without fetching it push/v1 ChatMetadata.message moved into a message_ref oneof; long messages now arrive as message_id only. Store the id on PushChatMetadata so it isn't silently dropped, but leave the GetMessage fetch as a TODO. message stays null and callers keep their existing no-message sync path, so behaviour is unchanged for now. --- .../com/flipcash/services/models/NotificationPayload.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt index 3500cd2fae..bc32f9bac1 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt @@ -49,6 +49,11 @@ data class PushChatMetadata( // delivered so the client can store the message, but a muted chat must not present a // notification for it. val muted: Boolean = false, + // Set when the server sent only the message's id (push/v1 ChatMetadata.message_ref, the + // `message_id` arm -- used for long messages instead of inlining `message`). Not yet + // fetched: see the TODO at ProtobufToLocal.asPayload(). [message] stays null in that case, + // and callers already fall back to their existing no-message sync path. + val messageId: Long? = null, ) data class NotificationPayload( From ab762c809fc1586e8f968eae8c2bce23e43159bd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:03:25 -0400 Subject: [PATCH 5/8] fix(chat): persist EncryptedContent ciphertext MessageContent.Encrypted was a marker data object, so Room only ever stored "we got an encrypted message here" and dropped the scheme/nonce/ciphertext. Once E2EE decryption ships, every message received before that point would be permanently unreadable even though the ciphertext was there to decrypt. Carry all three proto fields through: MessageContent.Encrypted is now a data class with a ByteArray-aware equals/hashCode, ProtobufToLocal maps Content.encrypted into it, and MessageContentSerialized.Encrypted persists scheme/nonce/ciphertext hex-encoded (matching how this codebase already hex-encodes other byte fields in ChatEntityMapper). Rendering is unchanged -- it still resolves to the unsupported-content tombstone. Adds a proto -> domain -> serialized -> domain round trip test and a proto/domain mapping test for Content.encrypted. --- .../converters/ChatTypeConverters.kt | 12 +++- .../converters/ChatTypeConvertersTest.kt | 25 +++++++ .../sources/mapper/chat/ChatEntityMapper.kt | 12 +++- .../mapper/chat/ChatEntityMapperTest.kt | 28 ++++++++ .../network/extensions/ProtobufToLocal.kt | 10 ++- .../services/models/chat/MessageContent.kt | 28 +++++++- .../MessageContentEncryptedMappingTest.kt | 65 +++++++++++++++++++ 7 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/MessageContentEncryptedMappingTest.kt diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index d2c205a3fe..3d10c69411 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -191,11 +191,17 @@ sealed interface MessageContentSerialized { @SerialName("system") data class System(val fallbackText: String) : MessageContentSerialized - // Placeholder for MessageContent.Encrypted -- see that type's doc. Nothing to persist - // beyond the marker; the content itself is never decoded client-side. + // Mirrors MessageContent.Encrypted -- see that type's doc. The content itself is never + // decoded client-side, but scheme/nonce/ciphertext are kept (hex-encoded) so a stored + // message can round-trip and be faithfully re-encoded later. Defaults keep pre-existing + // marker-only rows (written before this field set existed) decodable. @Serializable @SerialName("encrypted") - data object Encrypted : MessageContentSerialized + data class Encrypted( + val scheme: Int = 0, + val nonce: String = "", + val ciphertext: String = "", + ) : MessageContentSerialized } @Serializable diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index a752a6b171..3d5d263020 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -83,6 +83,31 @@ class ChatTypeConvertersTest { assertNull(converter.toMessageContentList(null)) } + @Test + fun `fromMessageContentList and toMessageContentList roundtrip encrypted content`() { + val original = listOf( + MessageContentSerialized.Encrypted( + scheme = 1, + nonce = "aabbccddeeff00112233445566778899aabbccddeeff00", + ciphertext = "0011223344556677", + ) + ) + val serialized = converter.toMessageContentList(original) + val deserialized = converter.fromMessageContentList(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `a marker-only encrypted row written before scheme, nonce and ciphertext existed still decodes`() { + // Shape MessageContentSerialized.Encrypted had before it carried these fields -- proves + // the new fields' defaults keep old rows from crashing on load. + val legacyJson = """[{"type":"encrypted"}]""" + + val result = converter.fromMessageContentList(legacyJson) + + assertEquals(listOf(MessageContentSerialized.Encrypted(scheme = 0, nonce = "", ciphertext = "")), result) + } + // endregion // region MessageStatus 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 8d45c1bbbe..5345e35d52 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 @@ -328,7 +328,11 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this deletedAt = deletedTs.epochSeconds, deletedBy = deletedBy?.hexEncodedString(), ) - is MessageContent.Encrypted -> MessageContentSerialized.Encrypted + is MessageContent.Encrypted -> MessageContentSerialized.Encrypted( + scheme = scheme, + nonce = nonce.toList().hexEncodedString(), + ciphertext = ciphertext.toList().hexEncodedString(), + ) } private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { @@ -358,7 +362,11 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { deletedTs = Instant.fromEpochSeconds(deletedAt), deletedBy = deletedBy?.hexToIdExt(), ) - is MessageContentSerialized.Encrypted -> MessageContent.Encrypted + is MessageContentSerialized.Encrypted -> MessageContent.Encrypted( + scheme = scheme, + nonce = nonce.hexToIdExt().toByteArray(), + ciphertext = ciphertext.hexToIdExt().toByteArray(), + ) } private fun MessagePointer.toSerialized(): MessagePointerSerialized = MessagePointerSerialized( diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 2fe69492de..47f0ab04fa 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -126,6 +126,34 @@ class ChatEntityMapperTest { assertEquals(false, entity(MessageContent.Text("still here")).isDeleted) } + /** + * `MessageContent.Encrypted` is a decode result kept verbatim (not decrypted) so it can be + * persisted and later re-encoded faithfully -- see that type's doc. A byte-for-byte round + * trip through the row proves the ciphertext isn't quietly discarded on the way through Room. + */ + @Test + fun `encrypted content survives the round trip through the message row`() { + val encrypted = MessageContent.Encrypted( + scheme = 1, + nonce = byteArrayOf(0x01, 0x02, 0x03, -1, 0x00), + ciphertext = byteArrayOf(-128, 0x7F, 0x10, 0x20, 0x30, 0x40), + ) + val message = ChatMessage( + messageId = 1, + senderId = listOf(0xAB.toByte()), + content = listOf(encrypted), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 1, + ) + + val entity = mapper.toEntity(CHAT_HEX, message) + val restored = mapper.toMessage(entity).content.single() as MessageContent.Encrypted + + assertEquals(1, restored.scheme) + assertEquals(true, encrypted.nonce.contentEquals(restored.nonce)) + assertEquals(true, encrypted.ciphertext.contentEquals(restored.ciphertext)) + } + private fun groupMetadata() = ChatMetadata( chatId = ChatId(CHAT_HEX), type = ChatType.GROUP, 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 5f743a8c0d..2f5f5471c0 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 @@ -195,8 +195,14 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { deletedBy = if (deleted.hasDeletedBy()) deleted.deletedBy.toId() else null, ) // Not decoded: E2EE crypto (X25519/HKDF/XChaCha20) is a cross-platform parity hotspot - // that needs its own decision. Rendered as unsupported rather than dropped. - MessagingModel.Content.TypeCase.ENCRYPTED -> MessageContent.Encrypted + // that needs its own decision. Rendered as unsupported rather than dropped, but the raw + // fields are kept verbatim so the message can round-trip through storage and be + // faithfully re-encoded (e.g. on edit) without losing the ciphertext. + MessagingModel.Content.TypeCase.ENCRYPTED -> MessageContent.Encrypted( + scheme = encrypted.schemeValue, + nonce = encrypted.nonce.toByteArray(), + ciphertext = encrypted.ciphertext.toByteArray(), + ) else -> MessageContent.Text("") } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 6583d62e7b..ec5572ae7e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -35,6 +35,30 @@ sealed interface MessageContent { ) : MessageContent // Placeholder for `messaging.v1.Content.encrypted` (DMs only). Crypto (X25519/HKDF/ // XChaCha20) is a cross-platform parity hotspot and needs its own decision; until then this - // renders as an unsupported message rather than being decoded. - data object Encrypted : MessageContent + // renders as an unsupported message rather than being decoded. The raw fields are kept + // verbatim (not decrypted) so the ciphertext survives persistence and can be faithfully + // re-encoded, rather than being lost the moment it is stored locally. + data class Encrypted( + val scheme: Int, + val nonce: ByteArray, + val ciphertext: ByteArray, + ) : MessageContent { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Encrypted) return false + return scheme == other.scheme && + nonce.contentEquals(other.nonce) && + ciphertext.contentEquals(other.ciphertext) + } + + override fun hashCode(): Int { + var result = scheme + result = 31 * result + nonce.contentHashCode() + result = 31 * result + ciphertext.contentHashCode() + return result + } + + override fun toString(): String = + "Encrypted(scheme=$scheme, nonce=${nonce.size} bytes, ciphertext=${ciphertext.size} bytes)" + } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/MessageContentEncryptedMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/MessageContentEncryptedMappingTest.kt new file mode 100644 index 0000000000..ed6524f604 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/MessageContentEncryptedMappingTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.services.internal.network.extensions + +import com.flipcash.services.models.chat.MessageContent +import com.google.protobuf.ByteString +import org.junit.Test +import kotlin.test.assertEquals +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel + +/** + * `messaging.v1.Content.encrypted` is never decrypted client-side -- see [MessageContent.Encrypted]'s + * doc -- but it has to survive proto -> domain -> proto intact, because [asContent] now re-sends it + * verbatim on an edit that rewrites an envelope around it, rather than throwing. + */ +class MessageContentEncryptedMappingTest { + + @Test + fun `Content encrypted decodes to MessageContent Encrypted with its fields intact`() { + val proto = MessagingModel.Content.newBuilder() + .setEncrypted( + MessagingModel.EncryptedContent.newBuilder() + .setSchemeValue(1) + .setNonce(ByteString.copyFrom(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8))) + .setCiphertext(ByteString.copyFrom(byteArrayOf(9, 10, 11, 12))) + ) + .build() + + val content = proto.toMessageContent() as MessageContent.Encrypted + + assertEquals(1, content.scheme) + assertEquals(true, byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8).contentEquals(content.nonce)) + assertEquals(true, byteArrayOf(9, 10, 11, 12).contentEquals(content.ciphertext)) + } + + @Test + fun `MessageContent Encrypted re-encodes to the same Content encrypted bytes`() { + val original = MessageContent.Encrypted( + scheme = 1, + nonce = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8), + ciphertext = byteArrayOf(9, 10, 11, 12), + ) + + val proto = original.asContent() + + assertEquals(MessagingModel.Content.TypeCase.ENCRYPTED, proto.typeCase) + assertEquals(1, proto.encrypted.schemeValue) + assertEquals(ByteString.copyFrom(original.nonce), proto.encrypted.nonce) + assertEquals(ByteString.copyFrom(original.ciphertext), proto.encrypted.ciphertext) + } + + @Test + fun `proto to domain to proto round trip preserves scheme, nonce and ciphertext`() { + val proto = MessagingModel.Content.newBuilder() + .setEncrypted( + MessagingModel.EncryptedContent.newBuilder() + .setSchemeValue(1) + .setNonce(ByteString.copyFrom(ByteArray(24) { it.toByte() })) + .setCiphertext(ByteString.copyFrom(ByteArray(32) { (it * 3).toByte() })) + ) + .build() + + val roundTripped = proto.toMessageContent().asContent() + + assertEquals(proto.encrypted, roundTripped.encrypted) + } +} From 20d974795acce978dc1a3bd7adc7ada6280cf454 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:03:36 -0400 Subject: [PATCH 6/8] fix(chat): re-encode encrypted content instead of throwing asContent() threw for MessageContent.Encrypted. Traced every caller: ChatMessagingService.sendMessage/editMessage both call it on a full MessageContent list built by the caller, including edit flows that reassemble an existing message (e.g. rewriting a reply's inner text) around content items the edit itself didn't touch. Retries and the outbox resend the same content list rather than reconstructing it, so they'd hit the same call. Any of those paths carrying a previously received EncryptedContent message forward -- an edit or retry on a message that happens to include one -- would crash instead of sending. Now that MessageContent.Encrypted carries the original scheme/nonce/ ciphertext (previous commit), asContent() re-encodes it into Content.encrypted using those fields, which is a faithful wire round-trip, not new encryption. This keeps all callers unchanged and leaves the actual encrypt path (composing new EncryptedContent) unimplemented, as before. --- .../network/extensions/LocalToProtobuf.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index b2690fec63..a0759eb81b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -171,10 +171,20 @@ internal fun MessageContent.asContent(): MessagingModel.Content { .setDeleted(deletedBuilder) .build() } - // The client never constructs this locally -- it only exists as a decode result for - // incoming `Content.encrypted` (see MessageContent.Encrypted). Sending EncryptedContent - // needs its own crypto implementation (X25519/HKDF/XChaCha20), tracked separately. - is MessageContent.Encrypted -> error("MessageContent.Encrypted cannot be sent; encryption is not implemented") + // The client never constructs this locally from scratch -- it only exists as a decode + // result for incoming `Content.encrypted` (see MessageContent.Encrypted). An edit that + // rewrites the text of a message envelope containing this (e.g. a reply body) re-sends + // the untouched fields verbatim, so this is a faithful round-trip of the original wire + // bytes, not new encryption. Encrypting new content needs its own crypto implementation + // (X25519/HKDF/XChaCha20), tracked separately. + is MessageContent.Encrypted -> MessagingModel.Content.newBuilder() + .setEncrypted( + MessagingModel.EncryptedContent.newBuilder() + .setSchemeValue(scheme) + .setNonce(nonce.toByteString()) + .setCiphertext(ciphertext.toByteString()) + ) + .build() } } From 18b07c7574b1dd0bcf78aedf918c4e077ad9b7d1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:03:58 -0400 Subject: [PATCH 7/8] fix(chat): persist chat_metadata.creator and use_e2ee Same gap as EncryptedContent, found while auditing this round trip: ChatMetadata.creator/useE2ee were added to the domain model and the network mapper, but ChatEntityMapper never wrote them to ChatMetadataEntity, so a chat rebuilt from Room after this session's earlier work would silently lose the group creator and the transitional E2EE flag on every reload. Adds chat_metadata.creator_hex (nullable, hex-encoded like every other user id column here) and use_e2ee (default 0), bumps the database to version 38 with an AutoMigration, and wires both through ChatEntityMapper's toEntity/toMetadata. --- .../38.json | 896 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 3 +- .../entities/ChatMetadataEntity.kt | 8 + .../sources/mapper/chat/ChatEntityMapper.kt | 4 + .../mapper/chat/ChatEntityMapperTest.kt | 35 + 5 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/38.json diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/38.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/38.json new file mode 100644 index 0000000000..4b54389202 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/38.json @@ -0,0 +1,896 @@ +{ + "formatVersion": 1, + "database": { + "version": 38, + "identityHash": "90305afebccf10e47b6feb3a43aa1fbb", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '90305afebccf10e47b6feb3a43aa1fbb')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 6d475592cc..45e6c15561 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -105,8 +105,9 @@ import com.getcode.utils.subByteArray // half-written messages this table exists to keep. AutoMigration(from = 35, to = 36), // chat_draft table AutoMigration(from = 36, to = 37, spec = FlipcashDatabase.Migration36To37::class), + AutoMigration(from = 37, to = 38), // chat_metadata.creator_hex (nullable), use_e2ee (default 0) ], - version = 37, + version = 38, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index bd7bd43afb..f13055a3c4 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -61,4 +61,12 @@ data class ChatMetadataEntity( // ViewerState.Permissions — a column no write has reached yet denies rather than grants. @ColumnInfo(name = "can_edit", defaultValue = "0") val canEdit: Boolean = false, + // Group creator; null for DMs and for group chats reconstructed without a server round + // trip. Stored hex-encoded, matching every other user id column in this database. + @ColumnInfo(name = "creator_hex") + val creatorHex: String? = null, + // Transitional E2EE flag (DMs only) -- see chat/v1 model.proto Metadata.use_e2ee. Ignored + // behaviourally for now; carried so a chat rebuilt from Room agrees with the network value. + @ColumnInfo(name = "use_e2ee", defaultValue = "0") + val useE2ee: Boolean = false, ) 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 5345e35d52..926028468f 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 @@ -90,6 +90,8 @@ class ChatEntityMapper @Inject constructor() { muteForever = isMuteForever(metadata.viewerState?.mute), viewerStateVersion = metadata.viewerState?.version ?: 0, canEdit = metadata.viewerState?.permissions?.canEdit ?: false, + creatorHex = metadata.creator?.hexEncodedString(), + useE2ee = metadata.useE2ee, ) } @@ -119,6 +121,8 @@ class ChatEntityMapper @Inject constructor() { // thing the write gate compares, so a metadata rebuilt from this row and pushed back // through an upsert is a no-op rather than a re-application of what is already there. viewerState = entity.toViewerState(), + creator = entity.creatorHex?.hexToIdExt(), + useE2ee = entity.useE2ee, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 47f0ab04fa..223e7909af 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -224,6 +224,41 @@ class ChatEntityMapperTest { assertEquals(null, entity.rulesJson) } + /** + * `ChatMetadata.creator`/`useE2ee` are only ever set from the network today + * ([toChatMetadata] in services/flipcash), but a chat rebuilt from Room goes through this + * mapper's [ChatEntityMapper.toEntity]/[ChatEntityMapper.toMetadata] round trip -- if either + * field were dropped there, a chat reloaded from the database would silently lose its group + * creator or its transitional E2EE flag. + */ + @Test + fun `group creator and use_e2ee survive the round trip through the row`() { + val metadata = groupMetadata().copy( + creator = listOf(0xCD.toByte()), + useE2ee = true, + ) + + val entity = mapper.toEntity(metadata) + assertEquals("cd", entity.creatorHex) + assertEquals(true, entity.useE2ee) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + assertEquals(listOf(0xCD.toByte()), restored.creator) + assertEquals(true, restored.useE2ee) + } + + @Test + fun `a chat with no creator and no e2ee round trips to null and false`() { + val entity = mapper.toEntity(metadata(latestEventSequence = 0)) + + assertEquals(null, entity.creatorHex) + assertEquals(false, entity.useE2ee) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + assertEquals(null, restored.creator) + assertEquals(false, restored.useE2ee) + } + @Test fun `a timed mute is written as its deadline`() { val entity = mapper.toEntity( From 0b5e0a0a67ad2338e7ce886b476c67fad65ad73f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:56:03 -0400 Subject: [PATCH 8/8] chore(deps): bump ocp-client-protocol to 0.6.0 and flipcash2-client-protocol to 0.12.0 Neither version is published yet, so this does not resolve until both client packages are released. --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1915e5496d..24b9520340 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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.5.0" -flipcash2-client-protocol = "0.11.0" +ocp-client-protocol = "0.6.0" +flipcash2-client-protocol = "0.12.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,