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