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/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/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt
index adae839637..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,7 +191,17 @@ sealed interface MessageContentSerialized {
@SerialName("system")
data class System(val fallbackText: String) : MessageContentSerialized
-
+ // 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 class Encrypted(
+ val scheme: Int = 0,
+ val nonce: String = "",
+ val ciphertext: String = "",
+ ) : MessageContentSerialized
}
@Serializable
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/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 f2b3bb2f10..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,
)
}
@@ -328,6 +332,11 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
deletedAt = deletedTs.epochSeconds,
deletedBy = deletedBy?.hexEncodedString(),
)
+ is MessageContent.Encrypted -> MessageContentSerialized.Encrypted(
+ scheme = scheme,
+ nonce = nonce.toList().hexEncodedString(),
+ ciphertext = ciphertext.toList().hexEncodedString(),
+ )
}
private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
@@ -357,6 +366,11 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
deletedTs = Instant.fromEpochSeconds(deletedAt),
deletedBy = deletedBy?.hexToIdExt(),
)
+ 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..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
@@ -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,
@@ -196,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(
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,
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/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt
index 4fa4217cb3..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,6 +171,20 @@ internal fun MessageContent.asContent(): MessagingModel.Content {
.setDeleted(deletedBuilder)
.build()
}
+ // 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()
}
}
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..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
@@ -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,15 @@ 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, 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("")
}
}
@@ -399,6 +413,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/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
}
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(
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,
)
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..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
@@ -33,4 +33,32 @@ 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. 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)
+ }
+}