From 70a07c7ad6dfd7022d9c5c3323ce902fa9d8d873 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 11:08:45 -0400 Subject: [PATCH 1/2] fix(chat): persist the viewer's EditChat grant ViewerState.permissions.canEdit is mapped from the proto into the domain model but never reached the database. ChatEntityMapper wrote mute and version only, and rebuilt ViewerState without permissions, so the grant came back false on every read. Screens take viewer state from the DB-backed observeMetadata rather than the fetch that produced it, so the server's answer was already gone by the time anything read it. The same function collapsed a chat to a null ViewerState when it held no mute at version 0, which discarded the grant for any chat nobody had muted. Adds can_edit to chat_metadata (schema 37), carries it through the mapper, the DAO and ChatMetadataDataSource, and counts it in the emptiness test. Migration36To37 zeroes viewer_state_version. The column arrives as 0 for every existing row, and updateViewerStateIfNewer only writes when the incoming version beats the stored one; a chat whose viewer state had not otherwise changed would report the version it already reported, lose the gate, and hold the grant at 0 until something unrelated moved it. Nothing is lost by re-arming, because every column that gate protects is server-owned and re-sent by the next fetch. --- .../37.json | 884 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 27 +- .../app/persistence/dao/ChatMetadataDao.kt | 14 +- .../entities/ChatMetadataEntity.kt | 6 + .../persistence/dao/ChatMetadataDaoTest.kt | 66 +- .../sources/ChatMetadataDataSource.kt | 1 + .../sources/mapper/chat/ChatEntityMapper.kt | 13 +- .../mapper/chat/ChatEntityMapperTest.kt | 35 + 8 files changed, 1037 insertions(+), 9 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/37.json diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/37.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/37.json new file mode 100644 index 0000000000..0b458f9081 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/37.json @@ -0,0 +1,884 @@ +{ + "formatVersion": 1, + "database": { + "version": 37, + "identityHash": "28f7a9680d331566f01217c5796350bf", + "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, 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" + } + ], + "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, '28f7a9680d331566f01217c5796350bf')" + ] + } +} \ 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 0df95e5de6..6d475592cc 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 @@ -104,8 +104,9 @@ import com.getcode.utils.subByteArray // cannot be re-fetched, so a version bump that dropped the file would delete the // 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), ], - version = 36, + version = 37, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -247,6 +248,30 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + /** + * Re-arms the viewer-state gate so the new `can_edit` column can actually be filled. + * + * The column arrives as 0 on every existing row, and [ChatMetadataDao.updateViewerStateIfNewer] + * writes only when the incoming version beats the stored one. A chat whose viewer state has + * not otherwise changed reports the version it already reported, so the first fetch after this + * migration would be dropped and the grant would stay 0 until something unrelated moved the + * version — the Edit affordance missing on every chat that existed before the upgrade. + * + * Zeroing the stored version lets that next fetch win. Nothing is lost: every column the gate + * guards is server-owned and re-sent by the same fetch. The mute columns are deliberately left + * alone, so a muted chat reads as muted until that fetch confirms it rather than flickering + * unmuted in between. + */ + class Migration36To37 : AutoMigrationSpec { + override fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL(REARM_VIEWER_STATE) + } + + companion object { + const val REARM_VIEWER_STATE = "UPDATE chat_metadata SET viewer_state_version = 0" + } + } + companion object { /** 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 f16dc5bc7f..cbaa40f824 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 @@ -129,16 +129,22 @@ interface ChatMetadataDao { * from re-applying itself on the way back through an upsert. * * Both mute columns are written together so a switch between the two shapes clears the other. + * + * The viewer's grants ride the same gate. They are part of the same server-owned state and + * carry no version of their own, so splitting them out would let a stale payload reinstate a + * grant the newest one had withdrawn. */ @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " + - "mute_forever = :muteForever, viewer_state_version = :version " + + "mute_forever = :muteForever, viewer_state_version = :version, " + + "can_edit = :canEdit " + "WHERE chat_id_hex = :chatIdHex AND :version > viewer_state_version" ) suspend fun updateViewerStateIfNewer( chatIdHex: String, muteUntilEpochMs: Long?, muteForever: Boolean, + canEdit: Boolean, version: Long, ) @@ -149,10 +155,13 @@ interface ChatMetadataDao { * version goes back to zero with it: a rejoin starts the server's numbering over, so a * retained version would make the gate reject the new state and the old mute would survive a * chat the user has left and re-entered. + * + * The grants go with it. A chat the user is no longer in grants nothing, and leaving one + * while holding an edit right would otherwise keep the affordance on screen. */ @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = NULL, mute_forever = 0, " + - "viewer_state_version = 0 WHERE chat_id_hex = :chatIdHex" + "viewer_state_version = 0, can_edit = 0 WHERE chat_id_hex = :chatIdHex" ) suspend fun clearViewerState(chatIdHex: String) @@ -189,6 +198,7 @@ interface ChatMetadataDao { chatIdHex = entity.chatIdHex, muteUntilEpochMs = entity.muteUntilEpochMs, muteForever = entity.muteForever, + canEdit = entity.canEdit, version = entity.viewerStateVersion, ) } 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 21639fa2e4..bd7bd43afb 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 @@ -55,4 +55,10 @@ data class ChatMetadataEntity( val muteForever: Boolean = false, @ColumnInfo(name = "viewer_state_version", defaultValue = "0") val viewerStateVersion: Long = 0, + // The viewer's server-computed grants. Stored rather than left to the fetch that carried it + // because every screen reads the viewer state back out of this row: a grant that lives only + // in the response is already gone by the time anything draws. Defaults false, matching + // ViewerState.Permissions — a column no write has reached yet denies rather than grants. + @ColumnInfo(name = "can_edit", defaultValue = "0") + val canEdit: Boolean = false, ) 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 29f74cc2c2..da5af65e4e 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 @@ -61,6 +61,7 @@ class ChatMetadataDaoTest { muteUntilEpochMs: Long? = null, muteForever: Boolean = false, viewerStateVersion: Long = 0, + canEdit: Boolean = false, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -78,6 +79,7 @@ class ChatMetadataDaoTest { muteUntilEpochMs = muteUntilEpochMs, muteForever = muteForever, viewerStateVersion = viewerStateVersion, + canEdit = canEdit, ) @Test @@ -224,6 +226,7 @@ class ChatMetadataDaoTest { chatIdHex = CHAT_HEX, muteUntilEpochMs = null, muteForever = false, + canEdit = false, version = 5, ) @@ -241,10 +244,10 @@ class ChatMetadataDaoTest { @Test fun `an older viewer state version leaves the mute alone`() = runTest { dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, version = 5) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, version = 5) // The mute this supersedes, arriving late. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, version = 4) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, canEdit = false, version = 4) val stored = requireNotNull(dao.getById(CHAT_HEX)) assertEquals(true, stored.muteForever) @@ -269,7 +272,7 @@ class ChatMetadataDaoTest { fun `switching to an indefinite mute clears the deadline`() = runTest { dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = 5_000, viewerStateVersion = 1)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, version = 2) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, version = 2) val stored = requireNotNull(dao.getById(CHAT_HEX)) assertNull(stored.muteUntilEpochMs) @@ -293,10 +296,65 @@ class ChatMetadataDaoTest { assertEquals(0L, stored.viewerStateVersion) // A rejoin's first state numbers from the bottom again, and must still apply. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, version = 1) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, canEdit = false, version = 1) assertEquals(9_000L, dao.getById(CHAT_HEX)?.muteUntilEpochMs) } + /** + * The grant carries no version of its own, so it is only ever as current as the viewer state + * that delivered it. A payload that loses the version race must not repaint it. + */ + @Test + fun `an older viewer state leaves the edit grant alone`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 4)) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 5) + + // The withdrawal this supersedes, arriving late. + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 3) + + assertEquals(true, dao.getById(CHAT_HEX)?.canEdit) + } + + /** + * A withdrawn grant has to reach the row. Ownership moves, and an Edit affordance left on + * screen offers an action the server answers with DENIED. + */ + @Test + fun `a newer viewer state withdraws the edit grant`() = runTest { + dao.upsert(entity(chatType = "GROUP", canEdit = true, viewerStateVersion = 4)) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 5) + + assertEquals(false, dao.getById(CHAT_HEX)?.canEdit) + } + + /** A chat the user has left grants nothing. */ + @Test + fun `clearing the viewer state drops the edit grant`() = runTest { + dao.upsert(entity(chatType = "GROUP", canEdit = true, viewerStateVersion = 7)) + + dao.clearViewerState(CHAT_HEX) + + assertEquals(false, dao.getById(CHAT_HEX)?.canEdit) + } + + /** + * Rows migrated from v36 hold the new column at 0 and the version the server last sent. The + * next fetch reports that same version, so without the re-arm it loses the gate and the grant + * stays 0 — the Edit affordance missing on every chat that predates the upgrade. + */ + @Test + fun `the v37 re-arm lets the next fetch fill the edit grant`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 7)) + + db.openHelper.writableDatabase.execSQL(FlipcashDatabase.Migration36To37.REARM_VIEWER_STATE) + + // The version the row already held, which the gate would otherwise drop. + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 7) + + assertEquals(true, dao.getById(CHAT_HEX)?.canEdit) + } + @Test fun `the viewer state version reads back, and is null for a chat that is not there`() = runTest { dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index d751bf88a6..3dc964ab34 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -81,6 +81,7 @@ class ChatMetadataDataSource @Inject constructor( chatIdHex = mapper.chatIdHex(chatId), muteUntilEpochMs = mapper.muteUntilEpochMs(viewerState.mute), muteForever = mapper.isMuteForever(viewerState.mute), + canEdit = viewerState.permissions.canEdit, version = viewerState.version, ) } 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 f83d371038..f2b3bb2f10 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 @@ -89,6 +89,7 @@ class ChatEntityMapper @Inject constructor() { muteUntilEpochMs = muteUntilEpochMs(metadata.viewerState?.mute), muteForever = isMuteForever(metadata.viewerState?.mute), viewerStateVersion = metadata.viewerState?.version ?: 0, + canEdit = metadata.viewerState?.permissions?.canEdit ?: false, ) } @@ -135,6 +136,10 @@ class ChatEntityMapper @Inject constructor() { * `isActiveAt(now)`'s answer, asked at render time. Dropping it here would make the row and * the domain model disagree about what is stored, and a later write gate compare against a * version whose state had been silently rewritten. + * + * A granted `can_edit` counts as state held about the viewer, so it keeps the row out of the + * null case on its own: a chat nobody has muted still has to report that the viewer may edit + * it, and returning null there would deny an edit the server allows. */ private fun ChatMetadataEntity.toViewerState(): ViewerState? { // Read into a local first: a smart cast will not hold across a module boundary. @@ -144,8 +149,12 @@ class ChatEntityMapper @Inject constructor() { until != null -> MuteState.Until(Instant.fromEpochMilliseconds(until)) else -> null } - if (mute == null && viewerStateVersion == 0L) return null - return ViewerState(mute = mute, version = viewerStateVersion) + if (mute == null && viewerStateVersion == 0L && !canEdit) return null + return ViewerState( + mute = mute, + version = viewerStateVersion, + permissions = ViewerState.Permissions(canEdit = canEdit), + ) } // endregion 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 e08e768076..2fe69492de 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 @@ -266,6 +266,41 @@ class ChatEntityMapperTest { assertEquals(null, restored.viewerState) } + @Test + fun `an edit grant survives the round trip`() { + val entity = mapper.toEntity( + groupMetadata().copy( + viewerState = ViewerState( + version = 3, + permissions = ViewerState.Permissions(canEdit = true), + ) + ) + ) + assertEquals(true, entity.canEdit) + + val restored = mapper.toMetadata(entity, emptyList(), null) + + assertEquals(true, restored.viewerState?.permissions?.canEdit) + } + + /** + * A grant is state held about the viewer even when nothing else is: an unmuted chat at + * version 0 that the server says is editable. Folding that row into a null viewer state + * would deny an edit the server allows, so the emptiness test has to count the grant. + */ + @Test + fun `a chat holding only an edit grant still has viewer state`() { + val entity = mapper.toEntity( + groupMetadata().copy( + viewerState = ViewerState(permissions = ViewerState.Permissions(canEdit = true)) + ) + ) + + val restored = mapper.toMetadata(entity, emptyList(), null) + + assertEquals(true, restored.viewerState?.permissions?.canEdit) + } + private companion object { const val CHAT_HEX = "aabbccdd" } From 476ec64463956107bb0bfa8f47faf50a25b99960 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:06:25 -0400 Subject: [PATCH 2/2] fix(chat): apply a viewer state that arrives at version zero The server bumps ViewerState.version only when the viewer's state changes, so a chat nobody has ever muted stays at version zero indefinitely. The gate on updateViewerStateIfNewer is a strict >, which made 0 > 0 false and rejected every payload such a chat ever sends. That was invisible while the gate only guarded mute columns, because the rejected payload said nothing new about them. It stopped being invisible once canEdit rode the same gate: the grant arrives on those zero-versioned payloads too, so it could never be stored. On a test account the server returns canEdit=true for a group at version zero and the edit affordance stayed hidden on it, while a group at version 7 showed it. A stored zero means nothing has ever been applied to the row, so anything the server sends beats it. Ordering is lost for that first write, but two payloads at version zero describe the same unchanged state; the strict gate resumes as soon as a non-zero version lands. Rows that predate this all sit at zero already, because the 36-to-37 migration rearmed them, so they pick the grant up on the next fetch without another migration. --- .../app/persistence/dao/ChatMetadataDao.kt | 12 +++++- .../persistence/dao/ChatMetadataDaoTest.kt | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) 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 cbaa40f824..7b096cb346 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 @@ -133,12 +133,22 @@ interface ChatMetadataDao { * The viewer's grants ride the same gate. They are part of the same server-owned state and * carry no version of their own, so splitting them out would let a stale payload reinstate a * grant the newest one had withdrawn. + * + * A stored version of zero is the exception, and it has to be: the server only bumps the + * version when the viewer's state changes, so a chat the viewer has never muted stays at + * zero forever and a strict `>` would reject its every payload — including the grants riding + * along with them. That is not hypothetical. `EditChat` is granted on chats at version zero, + * and gating them out left the edit affordance permanently hidden on a group its owner could + * in fact edit. Zero means nothing has ever been applied here, so anything the server sends + * beats it. Ordering is lost for that first write, but two payloads at version zero describe + * the same unchanged state, and the alternative is storing none of them. The strict gate + * resumes as soon as a non-zero version lands. */ @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " + "mute_forever = :muteForever, viewer_state_version = :version, " + "can_edit = :canEdit " + - "WHERE chat_id_hex = :chatIdHex AND :version > viewer_state_version" + "WHERE chat_id_hex = :chatIdHex AND (:version > viewer_state_version OR viewer_state_version = 0)" ) suspend fun updateViewerStateIfNewer( chatIdHex: String, 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 da5af65e4e..a47da549e3 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 @@ -255,6 +255,43 @@ class ChatMetadataDaoTest { assertEquals(5L, stored.viewerStateVersion) } + /** + * The server bumps `ViewerState.version` only when the viewer's state changes, so a chat the + * viewer has never muted sits at version zero indefinitely — and its grants ride in on those + * same zero-versioned payloads. A strict `>` rejected every one of them, which is how a group + * its owner could edit ended up with the edit affordance permanently hidden. + */ + @Test + fun `a viewer state at version zero lands on a row that has never had one`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) + + dao.updateViewerStateIfNewer( + chatIdHex = CHAT_HEX, + muteUntilEpochMs = null, + muteForever = false, + canEdit = true, + version = 0, + ) + + assertEquals(true, dao.getById(CHAT_HEX)?.canEdit) + } + + /** + * The exception is scoped to the stored zero. Once a real version has landed, a zero-versioned + * payload is a stale one and must not be able to withdraw the grant it carries. + */ + @Test + fun `a viewer state at version zero cannot overwrite a real one`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 3) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 0) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(true, stored.canEdit) + assertEquals(3L, stored.viewerStateVersion) + } + /** * The same gate an upsert goes through. A `ChatMetadata` rebuilt from this row reports the * version it was read at, so pushing it back must be a no-op rather than a re-application.