diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 08703a32dc..9b36ac8873 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -117,6 +117,15 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + @FeatureFlagMarker + data object GroupChats: FeatureFlag { + override val key: String = "group_chats_enabled" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -139,6 +148,7 @@ val FeatureFlag<*>.title: String FeatureFlag.ContactPickerMode -> "Contact Picker Mode" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" FeatureFlag.FrostedTipCard -> "Frosted Tip Card" + FeatureFlag.GroupChats -> "Group Chats" } val FeatureFlag<*>.message: String @@ -152,6 +162,7 @@ val FeatureFlag<*>.message: String FeatureFlag.ContactPickerMode -> "When enabled, contacts will be accessed via the system contact picker instead of requesting full READ_CONTACTS permission" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" FeatureFlag.FrostedTipCard -> "When enabled, the tip card in the scanner renders as frosted glass over a blurred snapshot of the camera instead of a solid card" + FeatureFlag.GroupChats -> "When enabled, group chats appear in your chat list alongside your direct messages" } diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/34.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/34.json new file mode 100644 index 0000000000..de73d97a2e --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/34.json @@ -0,0 +1,823 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "f408314a0898f09fa278c5ddc15715ae", + "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, 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" + } + ], + "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": "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, 'f408314a0898f09fa278c5ddc15715ae')" + ] + } +} \ 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 0098fac43d..115bff2173 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 @@ -94,8 +94,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 30, to = 31), // user_profiles.username (nullable) AutoMigration(from = 31, to = 32, spec = FlipcashDatabase.Migration31To32::class), AutoMigration(from = 32, to = 33), // chat_messages index on (chat_id_hex, timestamp_epoch_ms) + AutoMigration(from = 33, to = 34), // chat_metadata group columns: title, picture, roster, rules, membership ], - version = 33, + version = 34, ) @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 5af3e3dff3..cd61fb4133 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 @@ -119,6 +119,20 @@ class ChatTypeConverters { // endregion + // region ChatRules (group participation requirements) + + @TypeConverter + fun fromChatRules(value: String?): ChatRulesSerialized? { + return value?.let { runCatching { json.decodeFromString(it) }.getOrNull() } + } + + @TypeConverter + fun toChatRules(rules: ChatRulesSerialized?): String? { + return rules?.let { json.encodeToString(it) } + } + + // endregion + // region ReactionSummary @TypeConverter @@ -253,3 +267,29 @@ data class ReactorSerialized( val userIdHex: String, val reactedAtEpochSeconds: Long, ) + +/** + * Storage form of `ChatRules`. The domain type holds a `Fiat` and a list of `PublicKey`, + * neither of which is serializable, so the amount is flattened to quarks + currency code and + * each mint to its base58 string — the same shape `MessageContentSerialized.Cash` already uses. + */ +@Serializable +data class ChatRulesSerialized( + val listener: List = emptyList(), + val speaker: List = emptyList(), +) + +@Serializable +sealed interface ChatRuleRequirementSerialized { + @Serializable + @SerialName("minimum_balance") + data class MinimumBalance( + val quarks: Long, + val currencyCode: String, + val mints: List = emptyList(), + ) : ChatRuleRequirementSerialized + + @Serializable + @SerialName("staff") + data object Staff : ChatRuleRequirementSerialized +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 7841c95e7e..f60b544fee 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -5,7 +5,9 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import com.flipcash.app.persistence.converters.ChatRulesSerialized import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.services.models.chat.MediaItem import kotlinx.coroutines.flow.Flow @Dao @@ -23,13 +25,18 @@ interface ChatMetadataDao { /** * Overwrites only the columns the server owns. `latest_event_sequence` and * `analytics_counted_through` are client-owned watermarks that no server payload - * carries, so they are deliberately absent here. + * carries, so they are deliberately absent here. The roster columns are absent too — + * they are versioned, and go through [updateRosterIfNewer]. */ @Query( "UPDATE chat_metadata SET chat_type = :chatType, " + "last_activity_epoch_ms = :lastActivityEpochMs, " + "last_message_id = :lastMessageId, " + - "is_hidden = :isHidden " + + "is_hidden = :isHidden, " + + "title = :title, " + + "picture_json = :pictureJson, " + + "rules_json = :rulesJson, " + + "is_member = :isMember " + "WHERE chat_id_hex = :chatIdHex" ) suspend fun updateServerOwnedFields( @@ -38,8 +45,26 @@ interface ChatMetadataDao { lastActivityEpochMs: Long, lastMessageId: Long?, isHidden: Boolean, + title: String?, + pictureJson: MediaItem?, + rulesJson: ChatRulesSerialized?, + isMember: Boolean, ) + /** + * Applies a roster snapshot only when it is strictly newer than the stored one. + * + * `RosterSummary.version` advances by exactly one on every membership change, so a write + * carrying an older or equal version has nothing new to say. Guarding on it here is what + * stops a `ChatMetadata` rebuilt from the database — which reports version 0 — from + * clobbering a real member count on its way back through an upsert. + */ + @Query( + "UPDATE chat_metadata SET member_count = :memberCount, roster_version = :rosterVersion " + + "WHERE chat_id_hex = :chatIdHex AND :rosterVersion > roster_version" + ) + suspend fun updateRosterIfNewer(chatIdHex: String, memberCount: Long, rosterVersion: Long) + /** * Inserts a new chat, or refreshes an existing one's server-owned columns in place. * @@ -59,6 +84,15 @@ interface ChatMetadataDao { lastActivityEpochMs = entity.lastActivityEpochMs, lastMessageId = entity.lastMessageId, isHidden = entity.isHidden, + title = entity.title, + pictureJson = entity.pictureJson, + rulesJson = entity.rulesJson, + isMember = entity.isMember, + ) + updateRosterIfNewer( + chatIdHex = entity.chatIdHex, + memberCount = entity.memberCount, + rosterVersion = entity.rosterVersion, ) } 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 01af8f5547..940404a8b0 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 @@ -3,17 +3,45 @@ package com.flipcash.app.persistence.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import com.flipcash.app.persistence.converters.ChatRulesSerialized +import com.flipcash.services.models.chat.MediaItem @Entity(tableName = "chat_metadata") data class ChatMetadataEntity( - @PrimaryKey @ColumnInfo(name = "chat_id_hex") val chatIdHex: String, - @ColumnInfo(name = "chat_type") val chatType: String, - @ColumnInfo(name = "last_activity_epoch_ms", index = true) val lastActivityEpochMs: Long, - @ColumnInfo(name = "last_message_id") val lastMessageId: Long?, - @ColumnInfo(name = "latest_event_sequence", defaultValue = "0") val latestEventSequence: Long = 0, - @ColumnInfo(name = "is_hidden", defaultValue = "0") val isHidden: Boolean = false, + @PrimaryKey + @ColumnInfo(name = "chat_id_hex") + val chatIdHex: String, + @ColumnInfo(name = "chat_type") + val chatType: String, + @ColumnInfo(name = "last_activity_epoch_ms", index = true) + val lastActivityEpochMs: Long, + @ColumnInfo(name = "last_message_id") + val lastMessageId: Long?, + @ColumnInfo(name = "latest_event_sequence", defaultValue = "0") + val latestEventSequence: Long = 0, + @ColumnInfo(name = "is_hidden", defaultValue = "0") + val isHidden: Boolean = false, // Highest message id already counted into the received-analytics people // properties. Monotonic; guards non-idempotent increments against replay. @ColumnInfo(name = "analytics_counted_through", defaultValue = "0") val analyticsCountedThrough: Long = 0, + // Group identity. Null on a DM, where the counterparty's profile is the title and avatar. + @ColumnInfo(name = "title") + val title: String? = null, + @ColumnInfo(name = "picture_json") + val pictureJson: MediaItem? = null, + // The roster's true size and the version that decides whether a roster write applies. + // Zero on a DM, and on any group whose metadata was rebuilt without a server round trip. + @ColumnInfo(name = "member_count", defaultValue = "0") + val memberCount: Long = 0, + @ColumnInfo(name = "roster_version", defaultValue = "0") + val rosterVersion: Long = 0, + @ColumnInfo(name = "rules_json") + val rulesJson: ChatRulesSerialized? = null, + // Carried explicitly rather than inferred from the row's presence. Every group that reaches + // the device today is one you are in, so this is constant for now — but membership is what + // the gate, removal reconciliation and LeaveChat all turn on, and a state that important + // should be readable in the table rather than implied by whether a row exists. + @ColumnInfo(name = "is_member", defaultValue = "1") + val isMember: Boolean = true, ) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 411d1e1e4c..3c7c4593c7 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -4,7 +4,9 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.converters.ChatRulesSerialized import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.services.models.chat.MediaItem import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -47,6 +49,12 @@ class ChatMetadataDaoTest { latestEventSequence: Long = 0, isHidden: Boolean = false, analyticsCountedThrough: Long = 0, + title: String? = null, + pictureJson: MediaItem? = null, + memberCount: Long = 0, + rosterVersion: Long = 0, + rulesJson: ChatRulesSerialized? = null, + isMember: Boolean = true, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -55,6 +63,12 @@ class ChatMetadataDaoTest { latestEventSequence = latestEventSequence, isHidden = isHidden, analyticsCountedThrough = analyticsCountedThrough, + title = title, + pictureJson = pictureJson, + memberCount = memberCount, + rosterVersion = rosterVersion, + rulesJson = rulesJson, + isMember = isMember, ) @Test @@ -116,6 +130,59 @@ class ChatMetadataDaoTest { assertEquals(4L, dao.getLatestEventSequence(OTHER_HEX)) } + @Test + fun `group columns round-trip through an insert`() = runTest { + dao.upsert( + entity( + chatType = "GROUP", + title = "Flipcash Staff", + memberCount = 12, + rosterVersion = 4, + isMember = true, + ) + ) + + val stored = dao.getById(CHAT_HEX) + + assertEquals("Flipcash Staff", stored?.title) + assertEquals(12L, stored?.memberCount) + assertEquals(4L, stored?.rosterVersion) + assertEquals(true, stored?.isMember) + } + + @Test + fun `upsert refreshes the flat group columns`() = runTest { + dao.upsert(entity(chatType = "GROUP", title = "Old title", isMember = true)) + + dao.upsert(entity(chatType = "GROUP", title = "New title", isMember = false)) + + val stored = dao.getById(CHAT_HEX) + assertEquals("New title", stored?.title) + assertEquals(false, stored?.isMember) + } + + @Test + fun `a newer roster version replaces the roster columns`() = runTest { + dao.upsert(entity(chatType = "GROUP", memberCount = 12, rosterVersion = 4)) + + dao.upsert(entity(chatType = "GROUP", memberCount = 13, rosterVersion = 5)) + + val stored = dao.getById(CHAT_HEX) + assertEquals(13L, stored?.memberCount) + assertEquals(5L, stored?.rosterVersion) + } + + @Test + fun `an older roster version leaves the roster columns alone`() = runTest { + dao.upsert(entity(chatType = "GROUP", memberCount = 12, rosterVersion = 4)) + + dao.upsert(entity(chatType = "GROUP", memberCount = 0, rosterVersion = 0)) + + val stored = dao.getById(CHAT_HEX) + assertEquals(12L, stored?.memberCount) + assertEquals(4L, stored?.rosterVersion) + } + private companion object { const val CHAT_HEX = "aabbccdd" const val OTHER_HEX = "eeff0011" 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 4d69e0904d..ea034d00e5 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 @@ -1,5 +1,7 @@ package com.flipcash.app.persistence.sources.mapper.chat +import com.flipcash.app.persistence.converters.ChatRuleRequirementSerialized +import com.flipcash.app.persistence.converters.ChatRulesSerialized import com.flipcash.app.persistence.converters.EmojiReactionSerialized import com.flipcash.app.persistence.converters.MessageContentSerialized import com.flipcash.app.persistence.converters.MessagePointerSerialized @@ -22,6 +24,8 @@ import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.EmojiReaction @@ -30,11 +34,13 @@ import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.Reactor import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.ClientMessageId import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.base58 import com.getcode.utils.base58 import com.getcode.utils.base64 @@ -58,14 +64,24 @@ class ChatEntityMapper @Inject constructor() { * a message load or a delta sync. A fresh insert therefore starts at 0, meaning "this * transcript has never been fetched"; `ChatMetadataDao.upsert` leaves the column alone * on an existing row. + * + * [isMember] is not on [ChatMetadata]: every payload that carries a group today is one + * the caller is in, so it is a property of the write, not of the metadata. The feed and + * a join write `true`; a gated preview fetched by chat id writes `false`. */ - fun toEntity(metadata: ChatMetadata): ChatMetadataEntity { + fun toEntity(metadata: ChatMetadata, isMember: Boolean = true): ChatMetadataEntity { return ChatMetadataEntity( chatIdHex = metadata.chatId.bytes.toList().hexEncodedString(), chatType = metadata.type.name, lastActivityEpochMs = metadata.lastActivity.toEpochMilliseconds(), lastMessageId = metadata.lastMessage?.messageId, isHidden = metadata.isHidden, + title = metadata.title, + pictureJson = metadata.picture, + memberCount = metadata.rosterSummary.memberCount, + rosterVersion = metadata.rosterSummary.version, + rulesJson = metadata.rules?.toSerialized(), + isMember = isMember, ) } @@ -84,6 +100,13 @@ class ChatEntityMapper @Inject constructor() { // rebuilt from the database reports 0 — "unknown", not "no events". latestEventSequence = 0, isHidden = entity.isHidden, + title = entity.title, + picture = entity.pictureJson, + rosterSummary = RosterSummary( + memberCount = entity.memberCount, + version = entity.rosterVersion, + ), + rules = entity.rulesJson?.toDomain(), ) } @@ -303,6 +326,36 @@ private fun MessagePointerSerialized.toDomain(): MessagePointer = MessagePointer timestamp = Instant.fromEpochSeconds(timestampEpochSeconds), ) +private fun ChatRules.toSerialized(): ChatRulesSerialized = ChatRulesSerialized( + listener = listener.map { it.toSerialized() }, + speaker = speaker.map { it.toSerialized() }, +) + +private fun ChatRuleRequirement.toSerialized(): ChatRuleRequirementSerialized = when (this) { + is ChatRuleRequirement.MinimumBalance -> ChatRuleRequirementSerialized.MinimumBalance( + quarks = amount.quarks, + currencyCode = amount.currencyCode.name, + mints = mints.map { it.base58() }, + ) + ChatRuleRequirement.Staff -> ChatRuleRequirementSerialized.Staff +} + +private fun ChatRulesSerialized.toDomain(): ChatRules = ChatRules( + listener = listener.map { it.toDomain() }, + speaker = speaker.map { it.toDomain() }, +) + +private fun ChatRuleRequirementSerialized.toDomain(): ChatRuleRequirement = when (this) { + is ChatRuleRequirementSerialized.MinimumBalance -> ChatRuleRequirement.MinimumBalance( + amount = Fiat( + quarks = quarks, + currencyCode = CurrencyCode.tryValueOf(currencyCode) ?: CurrencyCode.USD, + ), + mints = mints.map { PublicKey(it.decodeBase58().toList()) }, + ) + ChatRuleRequirementSerialized.Staff -> ChatRuleRequirement.Staff +} + private fun SocialAccount.toSerialized(): SocialAccountSerialized = when (this) { is SocialAccount.TwitterX -> SocialAccountSerialized.TwitterX( id = id, 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 3b0e9b258f..516ad68170 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 @@ -10,7 +10,12 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.handle import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.RosterSummary +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat import org.junit.Assert.assertEquals import org.junit.Test import kotlin.time.Instant @@ -119,6 +124,76 @@ class ChatEntityMapperTest { assertEquals(false, entity(MessageContent.Text("still here")).isDeleted) } + private fun groupMetadata() = ChatMetadata( + chatId = ChatId(CHAT_HEX), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000), + title = "Flipcash Staff", + picture = null, + rosterSummary = RosterSummary(memberCount = 12, version = 4), + rules = ChatRules( + listener = listOf(ChatRuleRequirement.Staff), + speaker = listOf( + ChatRuleRequirement.MinimumBalance( + amount = Fiat(quarks = 500, currencyCode = CurrencyCode.USD), + mints = emptyList(), + ) + ), + ), + ) + + @Test + fun `group metadata writes its identity and roster onto the row`() { + val entity = mapper.toEntity(groupMetadata()) + + assertEquals("Flipcash Staff", entity.title) + assertEquals(12L, entity.memberCount) + assertEquals(4L, entity.rosterVersion) + assertEquals(true, entity.isMember) + } + + @Test + fun `a chat written as a non-member is marked as one`() { + val entity = mapper.toEntity(groupMetadata(), isMember = false) + + assertEquals(false, entity.isMember) + } + + @Test + fun `group rules survive the round trip`() { + val entity = mapper.toEntity(groupMetadata()) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + + assertEquals(listOf(ChatRuleRequirement.Staff), restored.rules?.listener) + val speaker = restored.rules?.speaker?.single() as ChatRuleRequirement.MinimumBalance + assertEquals(500L, speaker.amount.quarks) + assertEquals(CurrencyCode.USD, speaker.amount.currencyCode) + } + + @Test + fun `group identity and roster survive the round trip`() { + val entity = mapper.toEntity(groupMetadata()) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + + assertEquals("Flipcash Staff", restored.title) + assertEquals(12L, restored.rosterSummary.memberCount) + assertEquals(4L, restored.rosterSummary.version) + } + + @Test + fun `a DM keeps a null title and an empty roster`() { + val entity = mapper.toEntity(metadata(latestEventSequence = 0)) + + assertEquals(null, entity.title) + assertEquals(0L, entity.memberCount) + assertEquals(0L, entity.rosterVersion) + assertEquals(null, entity.rulesJson) + } + private companion object { const val CHAT_HEX = "aabbccdd" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b12d783ddb..4a14781fd2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.2" # 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.4.0" -flipcash2-client-protocol = "0.6.0" +flipcash2-client-protocol = "0.7.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/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt index 5e5861687c..c8eb45f503 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -31,4 +31,27 @@ class ChatController @Inject constructor( return repository.getDmChatFeed(owner, queryOptions, chatType) } + + suspend fun getGroupChatFeed( + queryOptions: QueryOptions = QueryOptions(), + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getGroupChatFeed(owner, queryOptions) + } + + suspend fun joinChat(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.joinChat(owner, chatId) + } + + suspend fun leaveChat(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.leaveChat(owner, chatId) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index 26a8191318..b38258c615 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -62,4 +62,52 @@ internal class ChatApi @Inject constructor( api.getDmChatFeed(request) } } + + suspend fun getGroupChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): RpcChatService.GetGroupChatFeedResponse { + val request = RpcChatService.GetGroupChatFeedRequest.newBuilder() + .setQueryOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getGroupChatFeed(request) + } + } + + suspend fun joinChat( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.JoinChatResponse { + val request = RpcChatService.JoinChatRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.joinChat(request) + } + } + + suspend fun leaveChat( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.LeaveChatResponse { + val request = RpcChatService.LeaveChatRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.leaveChat(request) + } + } } 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 a3c6c2589b..3dfeb85dc1 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 @@ -47,6 +47,7 @@ import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.RosterChange import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ReactionUpdate @@ -377,28 +378,7 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { return ChatMetadata( chatId = chatId.toChatId(), type = type.toChatType(), - members = membersList.map { member -> - ChatMember( - userId = member.userId.toId(), - userProfile = with (member.userProfile) { - UserProfile( - displayName = displayName, - socialAccounts = emptyList(), - phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, - email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, - profilePicture = if (hasProfilePicture()) profilePicture.toMediaItem() else null, - // Falls back to the member's own id: the server sets it on the member but - // usually not again inside the nested profile, and this profile is by - // definition that member's. Dropping it here leaves callers unable to name - // the profile that authorizes re-minting the picture's download URL, so the - // avatar can never recover once the stored URL expires. - userId = if (hasUserId()) userId.toId() else member.userId.toId(), - username = if (hasUsername()) username.value else null, - ) - }, - pointers = member.pointersList.map { it.toPointer() }, - ) - }, + members = membersList.map { it.toChatMember() }, lastMessage = if (hasLastMessage()) lastMessage.toChatMessage() else null, lastActivity = Instant.fromEpochSeconds(lastActivity.seconds, lastActivity.nanos), latestEventSequence = latestEventSequence, @@ -410,6 +390,31 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { ) } +// -- Chat member -- + +internal fun ChatModel.Member.toChatMember(): ChatMember { + return ChatMember( + userId = userId.toId(), + userProfile = with(userProfile) { + UserProfile( + displayName = displayName, + socialAccounts = emptyList(), + phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, + email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, + profilePicture = if (hasProfilePicture()) profilePicture.toMediaItem() else null, + // Falls back to the member's own id: the server sets it on the member but + // usually not again inside the nested profile, and this profile is by + // definition that member's. Dropping it here leaves callers unable to name + // the profile that authorizes re-minting the picture's download URL, so the + // avatar can never recover once the stored URL expires. + userId = if (hasUserId()) userId.toId() else this@toChatMember.userId.toId(), + username = if (hasUsername()) username.value else null, + ) + }, + pointers = pointersList.map { it.toPointer() }, + ) +} + // -- Chat roster summary -- internal fun ChatModel.RosterSummary.toRosterSummary(): RosterSummary { @@ -419,6 +424,32 @@ internal fun ChatModel.RosterSummary.toRosterSummary(): RosterSummary { ) } +// -- Chat roster changes -- + +/** + * Maps a wire roster update onto [RosterChange]. A kind-not-set update is dropped rather than + * defaulted, for the same reason malformed rules are: inventing a join would add a member the + * server never sent, and inventing a leave would remove one. + */ +internal fun ChatModel.RosterUpdate.toRosterChangeOrNull( + metadataMapper: (ChatModel.Metadata) -> ChatMetadata = { it.toChatMetadata() }, +): RosterChange? { + return when (kindCase) { + ChatModel.RosterUpdate.KindCase.MEMBER_JOINED -> RosterChange.MemberJoined( + member = memberJoined.member.toChatMember(), + metadata = if (memberJoined.hasMetadata()) metadataMapper(memberJoined.metadata) else null, + rosterSummary = rosterSummary.toRosterSummary(), + ) + + ChatModel.RosterUpdate.KindCase.MEMBER_LEFT -> RosterChange.MemberLeft( + userId = memberLeft.userId.toId(), + rosterSummary = rosterSummary.toRosterSummary(), + ) + + else -> null + } +} + // -- Chat participation rules -- internal fun ChatModel.Rules.toChatRules(): ChatRules { @@ -467,6 +498,9 @@ internal fun EventModel.ChatUpdate.toChatUpdate( metadataUpdates = metadataUpdatesList.map { it.toMetadataUpdate(metadataMapper) }, events = if (hasEvents()) events.eventsList.map { it.toChatEvent() } else emptyList(), reactionUpdates = if (hasReactionUpdates()) reactionUpdates.reactionUpdatesList.map { it.toReactionUpdate() } else emptyList(), + rosterUpdates = if (hasRosterUpdates()) { + rosterUpdates.rosterUpdatesList.mapNotNull { it.toRosterChangeOrNull(metadataMapper) } + } else emptyList(), ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index 72e28ba455..25eee5038e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -5,6 +5,9 @@ import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.flipcash.services.internal.network.api.ChatApi import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetDmChatFeedError +import com.flipcash.services.models.GetGroupChatFeedError +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.LeaveChatError import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType @@ -60,4 +63,71 @@ internal class ChatService @Inject constructor( } ) } + + suspend fun getGroupChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getGroupChatFeed(owner, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetGroupChatFeedResponse.Result.OK -> Result.success(response) + RpcChatService.GetGroupChatFeedResponse.Result.DENIED -> Result.failure(GetGroupChatFeedError.Denied()) + RpcChatService.GetGroupChatFeedResponse.Result.NOT_FOUND -> Result.failure(GetGroupChatFeedError.NotFound()) + RpcChatService.GetGroupChatFeedResponse.Result.UNRECOGNIZED -> Result.failure(GetGroupChatFeedError.Unrecognized()) + else -> Result.failure(GetGroupChatFeedError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetGroupChatFeedError.Other(cause = it) }) + } + ) + } + + suspend fun joinChat( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.joinChat(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.JoinChatResponse.Result.OK -> Result.success(response.chat) + RpcChatService.JoinChatResponse.Result.DENIED -> Result.failure(JoinChatError.Denied()) + RpcChatService.JoinChatResponse.Result.NOT_FOUND -> Result.failure(JoinChatError.NotFound()) + RpcChatService.JoinChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(JoinChatError.RulesNotSatisfied()) + RpcChatService.JoinChatResponse.Result.UNRECOGNIZED -> Result.failure(JoinChatError.Unrecognized()) + else -> Result.failure(JoinChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { JoinChatError.Other(cause = it) }) + } + ) + } + + suspend fun leaveChat( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.leaveChat(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.LeaveChatResponse.Result.OK -> Result.success(Unit) + RpcChatService.LeaveChatResponse.Result.DENIED -> Result.failure(LeaveChatError.Denied()) + RpcChatService.LeaveChatResponse.Result.NOT_FOUND -> Result.failure(LeaveChatError.NotFound()) + RpcChatService.LeaveChatResponse.Result.UNRECOGNIZED -> Result.failure(LeaveChatError.Unrecognized()) + else -> Result.failure(LeaveChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { LeaveChatError.Other(cause = it) }) + } + ) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index 0f5f039d3d..abcadefa0d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -3,6 +3,7 @@ package com.flipcash.services.internal.repositories import com.flipcash.services.internal.domain.ChatMetadataMapper import com.flipcash.services.internal.network.extensions.toPagingToken import com.flipcash.services.internal.network.services.ChatService +import com.flipcash.services.models.LeaveChatError import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId @@ -36,4 +37,32 @@ internal class InternalChatRepository( hasMore = response.hasMore, ) } + + override suspend fun getGroupChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result = service.getGroupChatFeed(owner, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + ChatFeedPage( + chats = response.chatsList.map { mapper.map(it) }, + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + ) + } + + override suspend fun joinChat( + owner: KeyPair, + chatId: ChatId, + ): Result = service.joinChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + .map { mapper.map(it) } + + override suspend fun leaveChat( + owner: KeyPair, + chatId: ChatId, + ): Result = service.leaveChat(owner, chatId) + // Recovered before reporting, so an already-left chat is not logged as a failure. + .recoverCatching { cause -> if (cause is LeaveChatError.NotFound) Unit else throw cause } + .onFailure { ErrorUtils.handleError(it) } } 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 a88786471e..36a11313d6 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 @@ -360,6 +360,42 @@ sealed class GetDmChatFeedError( data class Other(override val cause: Throwable? = null) : GetDmChatFeedError(message = cause?.message, cause = cause), NotifiableError } +sealed class GetGroupChatFeedError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetGroupChatFeedError("Denied") + class NotFound : GetGroupChatFeedError("Not found") + class Unrecognized : GetGroupChatFeedError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetGroupChatFeedError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class JoinChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : JoinChatError("Denied") + class NotFound : JoinChatError("Not found") + // The caller does not meet the chat's listener rules — a minimum balance, or staff + // membership. Recoverable by the user, so it is deliberately not a NotifiableError. + class RulesNotSatisfied : JoinChatError("Rules not satisfied") + class Unrecognized : JoinChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : JoinChatError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class LeaveChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : LeaveChatError("Denied") + // Recovered to success by InternalChatRepository.leaveChat: "the chat is not there" + // is the state LeaveChat was asked to produce. Kept as a distinct arm so the + // repository can tell it apart from Denied. + class NotFound : LeaveChatError("Not found") + class Unrecognized : LeaveChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : LeaveChatError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetMessageError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt index e20312423e..ecd3677ef6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt @@ -7,4 +7,7 @@ data class ChatUpdate( val metadataUpdates: List = emptyList(), val events: List = emptyList(), val reactionUpdates: List = emptyList(), + // Convergent, like reactionUpdates and unlike events: applied by RosterSummary.version, + // never gap-filled. See RosterChange. + val rosterUpdates: List = emptyList(), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterChange.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterChange.kt new file mode 100644 index 0000000000..bb39857e6b --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterChange.kt @@ -0,0 +1,41 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID + +/** + * A single change to a chat's roster: someone joined, or someone left. + * + * Roster changes ride the event stream as a convergent overlay, **outside** the gap-detected + * event log that [ChatEvent] belongs to. There is no sequence to fill a gap against — each + * change carries the roster's state after it, and a client applies one only when + * [rosterSummary]'s version is greater than the version it already holds, dropping anything + * older or equal. Delivery order therefore does not matter, and a missed change is reconciled + * by refetching the roster rather than by a delta. + */ +sealed interface RosterChange { + /** The chat's roster summary *after* this change. Decides whether the change applies. */ + val rosterSummary: RosterSummary + + /** + * [member] joined the chat, with their profile already hydrated so the cached member list + * can be updated without a refetch. + * + * [metadata] is set only when [member] is the recipient — that is, when *you* are the one + * who joined — and is the full chat snapshot to insert into the chat list. It is null for + * every other member's join. + */ + data class MemberJoined( + val member: ChatMember, + val metadata: ChatMetadata?, + override val rosterSummary: RosterSummary, + ) : RosterChange + + /** + * [userId] left the chat. When [userId] is the recipient, the recipient is no longer a + * member and the chat should be removed from their list. + */ + data class MemberLeft( + val userId: ID, + override val rosterSummary: RosterSummary, + ) : RosterChange +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt index d26a5f3a9d..673268e6bb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -18,4 +18,28 @@ interface ChatRepository { queryOptions: QueryOptions, chatType: ChatType, ): Result + + /** + * One page of the group feed. Unlike the DM feed there is no type filter: the server + * decides what a group is, and `GetGroupChatFeedRequest` carries only query options. + */ + suspend fun getGroupChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result + + /** Adds the caller to [chatId]'s roster, returning the chat as the caller now sees it. */ + suspend fun joinChat( + owner: KeyPair, + chatId: ChatId, + ): Result + + /** + * Removes the caller from [chatId]'s roster. Idempotent: a server `NOT_FOUND` is + * reported as success, because it describes the state the call was asked to produce. + */ + suspend fun leaveChat( + owner: KeyPair, + chatId: ChatId, + ): Result } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt index 5de99c3e32..61c7f0f9aa 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -162,6 +162,147 @@ class ChatControllerTest { // endregion + // region getGroupChatFeed + + @Test + fun `getGroupChatFeed fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.getGroupChatFeed() + + assertTrue(result.isFailure) + } + + @Test + fun `getGroupChatFeed uses default QueryOptions when none provided`() = runTest { + stubOwner() + repository.getGroupChatFeedResult = Result.success(ChatFeedPage(emptyList(), null, false)) + + controller.getGroupChatFeed() + + assertEquals(QueryOptions(), repository.lastQueryOptions) + } + + @Test + fun `getGroupChatFeed forwards the paging token`() = runTest { + stubOwner() + val token = listOf(0xAB.toByte()) + repository.getGroupChatFeedResult = Result.success(ChatFeedPage(emptyList(), null, false)) + + controller.getGroupChatFeed(QueryOptions(limit = 25, token = token)) + + assertEquals(25, repository.lastQueryOptions?.limit) + assertEquals(token, repository.lastQueryOptions?.token) + } + + @Test + fun `getGroupChatFeed returns page with chats and paging state`() = runTest { + stubOwner() + val nextToken = listOf(0xFF.toByte()) + repository.getGroupChatFeedResult = Result.success( + ChatFeedPage( + chats = listOf(stubMetadata(ChatId(ByteArray(32) { 1 }))), + pagingToken = nextToken, + hasMore = true, + ) + ) + + val returned = controller.getGroupChatFeed().getOrThrow() + + assertEquals(1, returned.chats.size) + assertEquals(nextToken, returned.pagingToken) + assertTrue(returned.hasMore) + } + + @Test + fun `getGroupChatFeed surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("server error") + repository.getGroupChatFeedResult = Result.failure(cause) + + val result = controller.getGroupChatFeed() + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region joinChat + + @Test + fun `joinChat fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.joinChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + } + + @Test + fun `joinChat returns the joined chat's metadata`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x42 }) + val expected = stubMetadata(chatId) + repository.joinChatResult = Result.success(expected) + + val result = controller.joinChat(chatId) + + assertEquals(chatId, repository.lastChatId) + assertSame(expected, result.getOrThrow()) + } + + @Test + fun `joinChat surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("rules not satisfied") + repository.joinChatResult = Result.failure(cause) + + val result = controller.joinChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region leaveChat + + @Test + fun `leaveChat fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.leaveChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + } + + @Test + fun `leaveChat forwards the chatId to the repository`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x7 }) + repository.leaveChatResult = Result.success(Unit) + + val result = controller.leaveChat(chatId) + + assertEquals(chatId, repository.lastChatId) + assertTrue(result.isSuccess) + } + + @Test + fun `leaveChat surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("denied") + repository.leaveChatResult = Result.failure(cause) + + val result = controller.leaveChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + // region helpers private fun stubMetadata(chatId: ChatId = ChatId(ByteArray(32))) = ChatMetadata( @@ -180,6 +321,9 @@ class ChatControllerTest { private class FakeChatRepository : ChatRepository { var getChatResult: Result = Result.failure(RuntimeException("not configured")) var getDmChatFeedResult: Result = Result.failure(RuntimeException("not configured")) + var getGroupChatFeedResult: Result = Result.failure(RuntimeException("not configured")) + var joinChatResult: Result = Result.failure(RuntimeException("not configured")) + var leaveChatResult: Result = Result.failure(RuntimeException("not configured")) var lastChatId: ChatId? = null var lastQueryOptions: QueryOptions? = null var lastChatType: ChatType? = null @@ -198,6 +342,24 @@ private class FakeChatRepository : ChatRepository { lastChatType = chatType return getDmChatFeedResult } + + override suspend fun getGroupChatFeed( + owner: Ed25519.KeyPair, + queryOptions: QueryOptions, + ): Result { + lastQueryOptions = queryOptions + return getGroupChatFeedResult + } + + override suspend fun joinChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastChatId = chatId + return joinChatResult + } + + override suspend fun leaveChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastChatId = chatId + return leaveChatResult + } } // endregion diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt new file mode 100644 index 0000000000..636b2d2e6a --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt @@ -0,0 +1,156 @@ +package com.flipcash.services.internal.network.extensions + +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.events.v1.Model as EventModel +import com.codeinc.flipcash.gen.profile.v1.Model as ProfileModel +import com.flipcash.services.models.chat.RosterChange +import com.google.protobuf.ByteString +import com.google.protobuf.Timestamp +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Roster changes are a convergent overlay outside the gap-detected event log, so they map to + * their own domain type and carry the post-change RosterSummary that decides whether to apply + * them. + */ +class RosterUpdateExtensionTest { + + private fun userId(byte: Byte): Common.UserId = + Common.UserId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(16) { byte })) + .build() + + private fun chatId(byte: Byte): Common.ChatId = + Common.ChatId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(32) { byte })) + .build() + + private fun rosterSummary(memberCount: Long, version: Long): ChatModel.RosterSummary = + ChatModel.RosterSummary.newBuilder() + .setMemberCount(memberCount) + .setVersion(version) + .build() + + private fun member(byte: Byte): ChatModel.Member = + ChatModel.Member.newBuilder() + .setUserId(userId(byte)) + .setUserProfile(ProfileModel.UserProfile.newBuilder().setDisplayName("Member $byte")) + .build() + + private fun metadata(byte: Byte): ChatModel.Metadata = + ChatModel.Metadata.newBuilder() + .setChatId(chatId(byte)) + .setType(ChatModel.ChatType.GROUP) + .setTitle("Flipcash Staff") + .setLastActivity(Timestamp.newBuilder().setSeconds(2000)) + .build() + + @Test + fun `member joined carries the member and the post-change summary`() { + val update = ChatModel.RosterUpdate.newBuilder() + .setMemberJoined( + ChatModel.RosterUpdate.MemberJoined.newBuilder().setMember(member(9)) + ) + .setRosterSummary(rosterSummary(memberCount = 3, version = 7)) + .build() + + val change = assertIs(update.toRosterChangeOrNull()) + + assertEquals(ByteArray(16) { 9 }.toList(), change.member.userId) + assertEquals("Member 9", change.member.userProfile.displayName) + assertEquals(3L, change.rosterSummary.memberCount) + assertEquals(7L, change.rosterSummary.version) + } + + @Test + fun `member joined has no metadata when the joiner is not the recipient`() { + val update = ChatModel.RosterUpdate.newBuilder() + .setMemberJoined( + ChatModel.RosterUpdate.MemberJoined.newBuilder().setMember(member(9)) + ) + .setRosterSummary(rosterSummary(memberCount = 3, version = 7)) + .build() + + val change = assertIs(update.toRosterChangeOrNull()) + + assertNull(change.metadata) + } + + @Test + fun `member joined carries the chat snapshot when the joiner is the recipient`() { + val update = ChatModel.RosterUpdate.newBuilder() + .setMemberJoined( + ChatModel.RosterUpdate.MemberJoined.newBuilder() + .setMember(member(9)) + .setMetadata(metadata(1)) + ) + .setRosterSummary(rosterSummary(memberCount = 3, version = 7)) + .build() + + val change = assertIs(update.toRosterChangeOrNull()) + + assertEquals("Flipcash Staff", assertNotNull(change.metadata).title) + } + + @Test + fun `member left carries the departing user and the post-change summary`() { + val update = ChatModel.RosterUpdate.newBuilder() + .setMemberLeft( + ChatModel.RosterUpdate.MemberLeft.newBuilder().setUserId(userId(4)) + ) + .setRosterSummary(rosterSummary(memberCount = 2, version = 8)) + .build() + + val change = assertIs(update.toRosterChangeOrNull()) + + assertEquals(ByteArray(16) { 4 }.toList(), change.userId) + assertEquals(2L, change.rosterSummary.memberCount) + assertEquals(8L, change.rosterSummary.version) + } + + @Test + fun `an update with no kind set is dropped rather than defaulted`() { + val update = ChatModel.RosterUpdate.newBuilder() + .setRosterSummary(rosterSummary(memberCount = 2, version = 8)) + .build() + + assertNull(update.toRosterChangeOrNull()) + } + + @Test + fun `chat update carries the roster batch`() { + val update = EventModel.ChatUpdate.newBuilder() + .setChat(chatId(1)) + .setRosterUpdates( + ChatModel.RosterUpdateBatch.newBuilder() + .addRosterUpdates( + ChatModel.RosterUpdate.newBuilder() + .setMemberLeft( + ChatModel.RosterUpdate.MemberLeft.newBuilder().setUserId(userId(4)) + ) + .setRosterSummary(rosterSummary(memberCount = 2, version = 8)) + ) + ) + .build() + + val domain = update.toChatUpdate() + + assertEquals(1, domain.rosterUpdates.size) + assertIs(domain.rosterUpdates.first()) + } + + @Test + fun `chat update with no roster batch has no roster changes`() { + val update = EventModel.ChatUpdate.newBuilder() + .setChat(chatId(1)) + .build() + + assertTrue(update.toChatUpdate().rosterUpdates.isEmpty()) + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt index 6cefb0a344..910c23d484 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt @@ -267,4 +267,51 @@ class ErrorsTest { assertSame(root, error.cause) assertEquals("jwt failure", error.message) } + + // -- GetGroupChatFeedError -- + + @Test + fun `GetGroupChatFeedError subtypes are CodeServerError`() { + assertIs(GetGroupChatFeedError.Denied()) + assertIs(GetGroupChatFeedError.NotFound()) + assertIs(GetGroupChatFeedError.Unrecognized()) + assertIs(GetGroupChatFeedError.Other()) + } + + @Test + fun `GetGroupChatFeedError Other preserves cause`() { + val root = RuntimeException("transport died") + assertSame(root, GetGroupChatFeedError.Other(root).cause) + } + + // -- JoinChatError -- + + @Test + fun `JoinChatError subtypes are CodeServerError`() { + assertIs(JoinChatError.Denied()) + assertIs(JoinChatError.NotFound()) + assertIs(JoinChatError.RulesNotSatisfied()) + assertIs(JoinChatError.Unrecognized()) + assertIs(JoinChatError.Other()) + } + + @Test + fun `JoinChatError RulesNotSatisfied has expected message`() { + assertEquals("Rules not satisfied", JoinChatError.RulesNotSatisfied().message) + } + + // -- LeaveChatError -- + + @Test + fun `LeaveChatError subtypes are CodeServerError`() { + assertIs(LeaveChatError.Denied()) + assertIs(LeaveChatError.NotFound()) + assertIs(LeaveChatError.Unrecognized()) + assertIs(LeaveChatError.Other()) + } + + @Test + fun `LeaveChatError NotFound has expected message`() { + assertEquals("Not found", LeaveChatError.NotFound().message) + } }