From 7e25498409158c54620b3c894037be218892d212 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 10 Aug 2026 19:03:24 -0400 Subject: [PATCH] refactor(persistence): normalize UserProfile into a shared user_profiles table The full UserProfile was serialized as a JSON blob and duplicated onto every chat_members row (once per membership) and onto blocked_users. This normalizes it into a single user_profiles table keyed by user_id_hex, joined back via @Relation. - New UserProfileEntity with decomposed scalar columns (display name, phone, email); the two inherently-nested values (social accounts, profile picture) stay serialized. - chat_members / blocked_users drop user_profile_json and join the shared profile via ChatMemberWithProfile / BlockedUserWithProfile. - v25->v26 manual migration: stages each user's legacy blob once (dedup across chats, chat's full blob preferred over the blocklist's name+avatar-only one), then table-recreates both tables to drop the column. Column drops avoid ALTER TABLE DROP COLUMN for minSdk-29 SQLite compatibility. - Preserve via backfill: a one-shot pass decomposes the staged blobs; reads also fall back to parsing a not-yet-backfilled blob, so correctness never depends on timing. - Two-writer safety: chat sync writes the authoritative full profile; blocklist sync writes only name+avatar via an INSERT OR REPLACE + correlated-subquery merge that never downgrades a richer cached profile (avoids ON CONFLICT DO UPDATE, absent on minSdk-29 SQLite). Tests: migration dedup + column-drop, backfill decomposition, partial-write preservation, and the chat-member relation join. --- .../26.json | 746 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 90 ++- .../app/persistence/UserProfileBackfill.kt | 21 + .../converters/ChatTypeConverters.kt | 28 + .../app/persistence/dao/BlockedUserDao.kt | 7 +- .../app/persistence/dao/ChatMemberDao.kt | 11 +- .../app/persistence/dao/UserProfileDao.kt | 62 ++ .../persistence/entities/BlockedUserEntity.kt | 23 +- .../persistence/entities/ChatMemberEntity.kt | 15 +- .../persistence/entities/UserProfileEntity.kt | 37 + .../entities/UserProfileEntityMapping.kt | 48 ++ .../persistence/UserProfileMigrationTest.kt | 202 +++++ .../persistence/provider/build.gradle.kts | 3 + .../app/persistence/PersistenceProvider.kt | 16 +- .../sources/BlockedUserDataSource.kt | 38 +- .../sources/ChatMemberDataSource.kt | 12 +- .../BlockedUserEntityToProfileMapper.kt | 21 +- .../blocklist/BlockedUserToEntityMapper.kt | 16 +- .../sources/mapper/chat/ChatEntityMapper.kt | 39 +- .../mediator/BlocklistRemoteMediator.kt | 6 +- 20 files changed, 1379 insertions(+), 62 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/26.json create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/UserProfileBackfill.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/26.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/26.json new file mode 100644 index 000000000..2b7b0082f --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/26.json @@ -0,0 +1,746 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "56839cd31d163b1feb99439af7c04431", + "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', 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'" + } + ], + "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, `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": "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, 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" + } + ], + "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, 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" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "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, `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": "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, '56839cd31d163b1feb99439af7c04431')" + ] + } +} \ 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 3306a1cec..297107dc8 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 @@ -21,6 +21,7 @@ import com.flipcash.app.persistence.dao.ContactDao import com.flipcash.app.persistence.dao.CurrencyCreatorDraftDao import com.flipcash.app.persistence.dao.MessageDao import com.flipcash.app.persistence.dao.TokenDao +import com.flipcash.app.persistence.dao.UserProfileDao import com.flipcash.app.persistence.entities.BlockedUserEntity import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMessageEntity @@ -32,6 +33,7 @@ import com.flipcash.app.persistence.entities.MessageEntity import com.flipcash.app.persistence.entities.SocialLinkEntity import com.flipcash.app.persistence.entities.TokenEntity import com.flipcash.app.persistence.entities.TokenValuationEntity +import com.flipcash.app.persistence.entities.UserProfileEntity import com.getcode.utils.TraceType import com.getcode.utils.trace import com.getcode.vendor.Base58 @@ -50,6 +52,7 @@ import com.getcode.utils.subByteArray ChatMessageEntity::class, ChatMemberEntity::class, BlockedUserEntity::class, + UserProfileEntity::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2, spec = FlipcashDatabase.Migration1To2::class), @@ -76,8 +79,11 @@ import com.getcode.utils.subByteArray AutoMigration(from = 22, to = 23, spec = FlipcashDatabase.Migration22To23::class), AutoMigration(from = 23, to = 24), AutoMigration(from = 24, to = 25), + // 25 -> 26 is a manual migration (MIGRATION_25_26): it normalizes the + // per-row user_profile_json blob into the shared user_profiles table, which + // needs data movement an AutoMigration can't express. ], - version = 25, + version = 26, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -90,6 +96,7 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun chatMessageDao(): ChatMessageDao abstract fun chatMemberDao(): ChatMemberDao abstract fun blockedUserDao(): BlockedUserDao + abstract fun userProfileDao(): UserProfileDao class Migration1To2 : Migration(1, 2), AutoMigrationSpec { override fun migrate(db: SupportSQLiteDatabase) { @@ -176,6 +183,86 @@ abstract class FlipcashDatabase : RoomDatabase() { } companion object { + + /** + * Normalizes the profile cache. Before v26 the full profile was serialized as + * `user_profile_json` and duplicated onto every `chat_members` row (one per + * membership) and onto `blocked_users`. v26 collapses it into a single + * `user_profiles` table keyed by `user_id_hex`, joined back via `@Relation`. + * + * The blob can't be decomposed into columns in SQL, so each user's blob is staged + * verbatim into `user_profiles.pending_migration_json` (one row per user; the full + * chat blob is preferred over the name+avatar-only blocked blob via `INSERT OR + * IGNORE`). A one-shot Kotlin backfill ([backfillMigratedProfiles]) decomposes it + * afterwards, and reads fall back to the staged blob until it runs. + * + * Column drops use table-recreate (not `ALTER TABLE ... DROP COLUMN`) for + * compatibility with the minSdk-29 SQLite build. + */ + val MIGRATION_25_26 = object : Migration(25, 26) { + override fun migrate(db: SupportSQLiteDatabase) { + // 1. New normalized table. Column set must match UserProfileEntity so + // Room's post-migration schema validation passes. + db.execSQL( + "CREATE TABLE IF NOT EXISTS `user_profiles` (" + + "`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, " + + "`pending_migration_json` TEXT, " + + "PRIMARY KEY(`user_id_hex`))" + ) + + // 2. Stage each user's legacy blob (one row per user). chat_members holds + // the full profile, so insert it first; blocked_users (name+avatar only) + // fills in users not in any chat. display_name is a placeholder until + // the backfill parses the blob. + db.execSQL( + "INSERT OR IGNORE INTO user_profiles (user_id_hex, display_name, pending_migration_json) " + + "SELECT user_id_hex, '', user_profile_json FROM chat_members " + + "WHERE user_profile_json IS NOT NULL" + ) + db.execSQL( + "INSERT OR IGNORE INTO user_profiles (user_id_hex, display_name, pending_migration_json) " + + "SELECT user_id_hex, '', user_profile_json FROM blocked_users " + + "WHERE user_profile_json IS NOT NULL" + ) + + // 3. Drop the duplicated blob column from chat_members via table-recreate. + db.execSQL( + "CREATE TABLE `chat_members_new` (" + + "`chat_id_hex` TEXT NOT NULL, " + + "`user_id_hex` TEXT NOT NULL, " + + "`pointers_json` TEXT, " + + "PRIMARY KEY(`chat_id_hex`, `user_id_hex`))" + ) + db.execSQL( + "INSERT INTO `chat_members_new` (chat_id_hex, user_id_hex, pointers_json) " + + "SELECT chat_id_hex, user_id_hex, pointers_json FROM chat_members" + ) + db.execSQL("DROP TABLE chat_members") + db.execSQL("ALTER TABLE chat_members_new RENAME TO chat_members") + + // 4. Same for blocked_users. + db.execSQL( + "CREATE TABLE `blocked_users_new` (" + + "`user_id_hex` TEXT NOT NULL, " + + "`blocked_at_epoch_ms` INTEGER NOT NULL, " + + "PRIMARY KEY(`user_id_hex`))" + ) + db.execSQL( + "INSERT INTO `blocked_users_new` (user_id_hex, blocked_at_epoch_ms) " + + "SELECT user_id_hex, blocked_at_epoch_ms FROM blocked_users" + ) + db.execSQL("DROP TABLE blocked_users") + db.execSQL("ALTER TABLE blocked_users_new RENAME TO blocked_users") + } + } + private var instance: FlipcashDatabase? = null fun requireInstance() = requireNotNull(instance) fun getInstance(): FlipcashDatabase? = instance @@ -206,6 +293,7 @@ abstract class FlipcashDatabase : RoomDatabase() { instance = Room.databaseBuilder(context, FlipcashDatabase::class.java, dbName) + .addMigrations(MIGRATION_25_26) .fallbackToDestructiveMigration() .build() diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/UserProfileBackfill.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/UserProfileBackfill.kt new file mode 100644 index 000000000..6bc377c46 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/UserProfileBackfill.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.persistence + +import com.flipcash.app.persistence.entities.decomposePending + +/** + * Decomposes any profiles the v25→v26 migration staged as raw JSON (see + * [FlipcashDatabase.MIGRATION_25_26]) into the normalized `user_profiles` columns, + * clearing the staging blob as it goes. + * + * Idempotent and safe to call repeatedly: it only touches rows that still carry a blob, + * processing them in batches until none remain. Reads already fall back to the staged + * blob, so this is best-effort cleanup rather than a correctness dependency. + */ +suspend fun FlipcashDatabase.backfillMigratedProfiles(batchSize: Int = 200) { + val dao = userProfileDao() + while (true) { + val batch = dao.pendingMigrationBatch(batchSize) + if (batch.isEmpty()) return + dao.update(batch.map { it.decomposePending() }) + } +} 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 d48f1f82c..f6188de75 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 @@ -90,6 +90,34 @@ class ChatTypeConverters { // endregion + // region SocialAccount list (normalized user_profiles column) + + @TypeConverter + fun fromSocialAccountList(value: String?): List? { + return value?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + } + + @TypeConverter + fun toSocialAccountList(accounts: List?): String? { + return accounts?.let { json.encodeToString(it) } + } + + // endregion + + // region MediaItem (normalized user_profiles avatar column) + + @TypeConverter + fun fromMediaItem(value: String?): MediaItem? { + return value?.let { runCatching { json.decodeFromString(it) }.getOrNull() } + } + + @TypeConverter + fun toMediaItem(item: MediaItem?): String? { + return item?.let { json.encodeToString(it) } + } + + // endregion + // region ReactionSummary @TypeConverter diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt index 447aa4738..3b1307561 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt @@ -7,16 +7,19 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.entities.BlockedUserWithProfile @Dao interface BlockedUserDao { /** Blocklist ordered most-recently-blocked first, matching the server's ordering. */ + @Transaction @Query("SELECT * FROM blocked_users ORDER BY blocked_at_epoch_ms DESC") - fun observePaged(): PagingSource + fun observePaged(): PagingSource + @Transaction @Query("SELECT * FROM blocked_users ORDER BY blocked_at_epoch_ms DESC") - suspend fun getAll(): List + suspend fun getAll(): List @Transaction @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt index 7ed212956..7f90333dd 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt @@ -4,20 +4,25 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMemberWithProfile import kotlinx.coroutines.flow.Flow @Dao interface ChatMemberDao { + @Transaction @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex") - suspend fun getMembersForChat(chatIdHex: String): List + suspend fun getMembersForChat(chatIdHex: String): List + @Transaction @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex") - fun observeMembersForChat(chatIdHex: String): Flow> + fun observeMembersForChat(chatIdHex: String): Flow> + @Transaction @Query("SELECT * FROM chat_members") - fun observeAll(): Flow> + fun observeAll(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(entity: ChatMemberEntity) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt new file mode 100644 index 000000000..5a22e4582 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.flipcash.app.persistence.entities.UserProfileEntity +import com.flipcash.services.models.chat.MediaItem + +@Dao +interface UserProfileDao { + + /** + * Authoritative full-profile write (chat member sync). The caller has the complete + * profile, so a whole-row replace is correct — and it clears any staged migration blob. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertFull(profiles: List) + + /** + * Partial write for callers that only know name + avatar (blocklist sync). Inserts a new + * row, or updates *only* those two columns on an existing one — so it never downgrades a + * richer profile already cached from a chat, and never clears a pending migration blob. + * + * Uses `INSERT OR REPLACE` with correlated sub-selects (rather than `ON CONFLICT DO + * UPDATE`) so it works on the minSdk-29 SQLite build, which predates UPSERT. The + * sub-selects read the current row before the replace, preserving every column the + * blocklist doesn't know about; the avatar keeps its existing value when [profilePicture] + * is null via COALESCE. + */ + @Query( + """ + INSERT OR REPLACE INTO user_profiles ( + user_id_hex, display_name, phone_value, phone_verified, + email_value, email_verified, social_accounts_json, + profile_picture_json, pending_migration_json + ) VALUES ( + :userIdHex, + :displayName, + (SELECT phone_value FROM user_profiles WHERE user_id_hex = :userIdHex), + (SELECT phone_verified FROM user_profiles WHERE user_id_hex = :userIdHex), + (SELECT email_value FROM user_profiles WHERE user_id_hex = :userIdHex), + (SELECT email_verified FROM user_profiles WHERE user_id_hex = :userIdHex), + (SELECT social_accounts_json FROM user_profiles WHERE user_id_hex = :userIdHex), + COALESCE(:profilePicture, (SELECT profile_picture_json FROM user_profiles WHERE user_id_hex = :userIdHex)), + (SELECT pending_migration_json FROM user_profiles WHERE user_id_hex = :userIdHex) + ) + """ + ) + suspend fun upsertNameAndAvatar(userIdHex: String, displayName: String, profilePicture: MediaItem?) + + /** A batch of rows still carrying a staged legacy blob; drives [backfillMigratedProfiles]. */ + @Query("SELECT * FROM user_profiles WHERE pending_migration_json IS NOT NULL LIMIT :limit") + suspend fun pendingMigrationBatch(limit: Int): List + + @Update + suspend fun update(profiles: List) + + @Query("DELETE FROM user_profiles") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/BlockedUserEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/BlockedUserEntity.kt index e82647df9..cfd6c8670 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/BlockedUserEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/BlockedUserEntity.kt @@ -1,21 +1,32 @@ package com.flipcash.app.persistence.entities import androidx.room.ColumnInfo +import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey -import com.flipcash.app.persistence.converters.UserProfileSerialized +import androidx.room.Relation /** * A single user on the current account's blocklist, cached for offline display. * - * The server's blocklist entry only carries the user id + when they were blocked, so the - * display profile ([userProfileJson], resolved separately when the page is fetched) is embedded - * here — mirroring how [ChatMemberEntity] embeds a member's profile — so the list renders name + - * avatar without a per-row network lookup. + * The server's blocklist entry only carries the user id + when they were blocked; the + * display profile (name + avatar) lives in the shared, normalized [UserProfileEntity] + * and is joined via [BlockedUserWithProfile] so the list renders without a per-row + * network lookup. */ @Entity(tableName = "blocked_users") data class BlockedUserEntity( @PrimaryKey @ColumnInfo(name = "user_id_hex") val userIdHex: String, @ColumnInfo(name = "blocked_at_epoch_ms") val blockedAtEpochMs: Long, - @ColumnInfo(name = "user_profile_json") val userProfileJson: UserProfileSerialized?, +) + +/** + * A blocklist row joined to the shared, normalized [UserProfileEntity]. [profile] may be + * null if the user's profile hasn't been cached yet; the read mapper falls back to + * empty display data in that case. + */ +data class BlockedUserWithProfile( + @Embedded val blocked: BlockedUserEntity, + @Relation(parentColumn = "user_id_hex", entityColumn = "user_id_hex") + val profile: UserProfileEntity?, ) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMemberEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMemberEntity.kt index cb1cce82c..7d89127e6 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMemberEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMemberEntity.kt @@ -1,9 +1,10 @@ package com.flipcash.app.persistence.entities import androidx.room.ColumnInfo +import androidx.room.Embedded import androidx.room.Entity +import androidx.room.Relation import com.flipcash.app.persistence.converters.MessagePointerSerialized -import com.flipcash.app.persistence.converters.UserProfileSerialized @Entity( tableName = "chat_members", @@ -12,6 +13,16 @@ import com.flipcash.app.persistence.converters.UserProfileSerialized data class ChatMemberEntity( @ColumnInfo(name = "chat_id_hex") val chatIdHex: String, @ColumnInfo(name = "user_id_hex") val userIdHex: String, - @ColumnInfo(name = "user_profile_json") val userProfileJson: UserProfileSerialized?, @ColumnInfo(name = "pointers_json") val pointersJson: List?, ) + +/** + * A chat member row joined to the shared, normalized [UserProfileEntity]. [profile] is + * null until the member's profile has been synced (or, transiently, before the v26 + * migration backfill runs — the read mapper falls back to the staged blob in that case). + */ +data class ChatMemberWithProfile( + @Embedded val member: ChatMemberEntity, + @Relation(parentColumn = "user_id_hex", entityColumn = "user_id_hex") + val profile: UserProfileEntity?, +) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt new file mode 100644 index 000000000..ab28cd621 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.flipcash.app.persistence.converters.SocialAccountSerialized +import com.flipcash.services.models.chat.MediaItem + +/** + * A single cached copy of a user's public profile, keyed by user id. + * + * Previously the profile was serialized as a JSON blob and duplicated onto *every* + * `chat_members` row (once per membership) and onto `blocked_users`. This table + * normalizes it to one row per user, joined back via `@Relation` on `user_id_hex` + * (see [ChatMemberWithProfile] / [BlockedUserWithProfile]). + * + * Scalar fields are decomposed into real columns. The two inherently-nested values + * ([socialAccounts], [profilePicture]) stay serialized because modelling them as + * columns would require child tables for no real query benefit. + * + * [pendingMigrationJson] is transient migration scaffolding: the v25→v26 migration + * copies each legacy blob here verbatim (it cannot be decomposed in SQL), and a + * one-shot backfill parses it into the columns and nulls it out. Reads fall back to + * parsing it, so a not-yet-backfilled row still renders. New writes never set it. + */ +@Entity(tableName = "user_profiles") +data class UserProfileEntity( + @PrimaryKey @ColumnInfo(name = "user_id_hex") val userIdHex: String, + @ColumnInfo(name = "display_name") val displayName: String, + @ColumnInfo(name = "phone_value") val phoneValue: String?, + @ColumnInfo(name = "phone_verified") val phoneVerified: Boolean?, + @ColumnInfo(name = "email_value") val emailValue: String?, + @ColumnInfo(name = "email_verified") val emailVerified: Boolean?, + @ColumnInfo(name = "social_accounts_json") val socialAccounts: List?, + @ColumnInfo(name = "profile_picture_json") val profilePicture: MediaItem?, + @ColumnInfo(name = "pending_migration_json") val pendingMigrationJson: String? = null, +) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt new file mode 100644 index 000000000..f759f4c78 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt @@ -0,0 +1,48 @@ +package com.flipcash.app.persistence.entities + +import com.flipcash.app.persistence.converters.ChatTypeConverters +import com.flipcash.app.persistence.converters.UserProfileSerialized +import com.flipcash.services.models.VerifiableContactMethod + +// A single shared converter instance; only used to parse the transient legacy blob. +private val migrationConverters = ChatTypeConverters() + +/** + * Reconstructs the serialized profile from a normalized [UserProfileEntity]. + * + * If the row still carries a [UserProfileEntity.pendingMigrationJson] blob (migrated from + * v25 but not yet decomposed by the backfill), that blob wins — so reads render correctly + * regardless of backfill timing. Otherwise the decomposed columns are used. + */ +fun UserProfileEntity.toSerialized(): UserProfileSerialized { + pendingMigrationJson?.let { pending -> + migrationConverters.fromUserProfile(pending)?.let { return it } + } + return UserProfileSerialized( + displayName = displayName, + socialAccounts = socialAccounts.orEmpty(), + phoneNumber = phoneValue?.let { VerifiableContactMethod(it, phoneVerified ?: false) }, + email = emailValue?.let { VerifiableContactMethod(it, emailVerified ?: false) }, + profilePicture = profilePicture, + ) +} + +/** + * Decomposes a row's staged legacy blob into the real columns, clearing the staging field. + * Returns the same row unchanged if there's nothing staged. Used by [backfillMigratedProfiles]. + */ +internal fun UserProfileEntity.decomposePending(): UserProfileEntity { + val pending = pendingMigrationJson ?: return this + val parsed = migrationConverters.fromUserProfile(pending) + ?: return copy(pendingMigrationJson = null) + return copy( + displayName = parsed.displayName?.takeIf { it.isNotEmpty() } ?: displayName, + phoneValue = parsed.phoneNumber?.value, + phoneVerified = parsed.phoneNumber?.verified, + emailValue = parsed.email?.value, + emailVerified = parsed.email?.verified, + socialAccounts = parsed.socialAccounts, + profilePicture = parsed.profilePicture, + pendingMigrationJson = null, + ) +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt new file mode 100644 index 000000000..e4285b466 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt @@ -0,0 +1,202 @@ +package com.flipcash.app.persistence + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.converters.ChatTypeConverters +import com.flipcash.app.persistence.converters.SocialAccountSerialized +import com.flipcash.app.persistence.converters.UserProfileSerialized +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.UserProfileEntity +import com.flipcash.app.persistence.entities.decomposePending +import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.MediaItem +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +class UserProfileMigrationTest { + + private val context = ApplicationProvider.getApplicationContext() + private val converters = ChatTypeConverters() + + private val fullProfileJson = converters.toUserProfile( + UserProfileSerialized( + displayName = "Alice", + socialAccounts = listOf( + SocialAccountSerialized.TwitterX( + id = "1", username = "alice", name = "Alice", + description = "d", profilePicUrl = "u", verifiedType = "BLUE", followerCount = 5, + ) + ), + phoneNumber = VerifiableContactMethod("+15551234567", verified = true), + email = VerifiableContactMethod("a@b.com", verified = false), + profilePicture = MediaItem(renditions = emptyList()), + ) + )!! + + private val partialProfileJson = converters.toUserProfile( + UserProfileSerialized( + displayName = "Bob", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + profilePicture = null, + ) + )!! + + private fun openV25Database(): SupportSQLiteDatabase { + val callback = object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + } + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(null) // in-memory + .callback(callback) + .build() + val db = FrameworkSQLiteOpenHelperFactory().create(config).writableDatabase + // The v25 shape: profile duplicated as a JSON blob on both tables. + db.execSQL( + "CREATE TABLE chat_members (chat_id_hex TEXT NOT NULL, user_id_hex TEXT NOT NULL, " + + "user_profile_json TEXT, pointers_json TEXT, PRIMARY KEY(chat_id_hex, user_id_hex))" + ) + db.execSQL( + "CREATE TABLE blocked_users (user_id_hex TEXT NOT NULL, blocked_at_epoch_ms INTEGER NOT NULL, " + + "user_profile_json TEXT, PRIMARY KEY(user_id_hex))" + ) + return db + } + + private fun SupportSQLiteDatabase.columns(table: String): Set { + query("PRAGMA table_info($table)").use { c -> + val names = mutableSetOf() + val nameIdx = c.getColumnIndex("name") + while (c.moveToNext()) names += c.getString(nameIdx) + return names + } + } + + private fun SupportSQLiteDatabase.count(table: String): Int = + query("SELECT COUNT(*) FROM $table").use { it.moveToFirst(); it.getInt(0) } + + private fun SupportSQLiteDatabase.pendingFor(userId: String): String? = + query("SELECT pending_migration_json FROM user_profiles WHERE user_id_hex = ?", arrayOf(userId)) + .use { if (it.moveToFirst()) it.getString(0) else null } + + @Test + fun `migration 25 to 26 normalizes duplicated blobs into one profile row per user`() { + val db = openV25Database() + // Same user's profile duplicated across two chats — the core redundancy this fixes. + db.execSQL( + "INSERT INTO chat_members VALUES (?,?,?,?)", + arrayOf("chatA", "u1", fullProfileJson, null), + ) + db.execSQL( + "INSERT INTO chat_members VALUES (?,?,?,?)", + arrayOf("chatB", "u1", fullProfileJson, null), + ) + // A blocked user not in any chat, carrying only a partial (name+avatar) blob. + db.execSQL( + "INSERT INTO blocked_users VALUES (?,?,?)", + arrayOf("u2", 111L, partialProfileJson), + ) + + FlipcashDatabase.MIGRATION_25_26.migrate(db) + + // One profile row per user (u1 deduped across its two memberships). + assertEquals(2, db.count("user_profiles")) + assertEquals(fullProfileJson, db.pendingFor("u1")) + assertEquals(partialProfileJson, db.pendingFor("u2")) + + // The duplicated blob column is gone from both tables; the rows themselves survive. + assertFalse("user_profile_json" in db.columns("chat_members")) + assertFalse("user_profile_json" in db.columns("blocked_users")) + assertEquals(setOf("chat_id_hex", "user_id_hex", "pointers_json"), db.columns("chat_members")) + assertEquals(2, db.count("chat_members")) + assertEquals(1, db.count("blocked_users")) + + db.close() + } + + @Test + fun `decomposePending parses a staged blob into the normalized columns`() { + val staged = UserProfileEntity( + userIdHex = "u1", + displayName = "", + phoneValue = null, phoneVerified = null, + emailValue = null, emailVerified = null, + socialAccounts = null, profilePicture = null, + pendingMigrationJson = fullProfileJson, + ) + + val decomposed = staged.decomposePending() + + assertEquals("Alice", decomposed.displayName) + assertEquals("+15551234567", decomposed.phoneValue) + assertEquals(true, decomposed.phoneVerified) + assertEquals("a@b.com", decomposed.emailValue) + assertEquals(false, decomposed.emailVerified) + assertEquals(1, decomposed.socialAccounts?.size) + assertNotNull(decomposed.profilePicture) + assertNull(decomposed.pendingMigrationJson) // staging cleared + } +} + +@RunWith(RobolectricTestRunner::class) +class UserProfileDaoTest { + + private val context = ApplicationProvider.getApplicationContext() + private val db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + + @After + fun tearDown() = db.close() + + private fun fullProfile(userId: String) = UserProfileEntity( + userIdHex = userId, + displayName = "Alice", + phoneValue = "+15551234567", phoneVerified = true, + emailValue = "a@b.com", emailVerified = false, + socialAccounts = emptyList(), + profilePicture = MediaItem(renditions = emptyList()), + ) + + @Test + fun `partial name+avatar write does not downgrade a richer chat profile`() = runBlocking { + val dao = db.userProfileDao() + dao.upsertFull(listOf(fullProfile("u1"))) + + // Blocklist sync only knows name + avatar — must not wipe phone/email/social. + dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "Alice (blocked)", profilePicture = null) + + db.openHelper.writableDatabase.query("SELECT display_name, phone_value, email_value FROM user_profiles WHERE user_id_hex = 'u1'").use { + it.moveToFirst() + assertEquals("Alice (blocked)", it.getString(0)) // name updated + assertEquals("+15551234567", it.getString(1)) // phone preserved + assertEquals("a@b.com", it.getString(2)) // email preserved + } + } + + @Test + fun `chat member relation joins the shared normalized profile`() = runBlocking { + db.userProfileDao().upsertFull(listOf(fullProfile("u1"))) + db.chatMemberDao().upsert(ChatMemberEntity(chatIdHex = "chatA", userIdHex = "u1", pointersJson = null)) + + val members = db.chatMemberDao().getMembersForChat("chatA") + + assertEquals(1, members.size) + assertEquals("u1", members.first().member.userIdHex) + assertEquals("Alice", members.first().profile?.displayName) + } +} diff --git a/apps/flipcash/shared/persistence/provider/build.gradle.kts b/apps/flipcash/shared/persistence/provider/build.gradle.kts index 7f3b34024..20821f830 100644 --- a/apps/flipcash/shared/persistence/provider/build.gradle.kts +++ b/apps/flipcash/shared/persistence/provider/build.gradle.kts @@ -15,6 +15,9 @@ android { dependencies { implementation(project(":apps:flipcash:shared:persistence:db")) + // Needed to reference FlipcashDatabase's RoomDatabase supertype at the call site + // for the post-migration profile backfill. + implementation(libs.bundles.room) testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) diff --git a/apps/flipcash/shared/persistence/provider/src/main/kotlin/com/flipcash/app/persistence/PersistenceProvider.kt b/apps/flipcash/shared/persistence/provider/src/main/kotlin/com/flipcash/app/persistence/PersistenceProvider.kt index 19f582f47..6f1b54e8a 100644 --- a/apps/flipcash/shared/persistence/provider/src/main/kotlin/com/flipcash/app/persistence/PersistenceProvider.kt +++ b/apps/flipcash/shared/persistence/provider/src/main/kotlin/com/flipcash/app/persistence/PersistenceProvider.kt @@ -2,6 +2,10 @@ package com.flipcash.app.persistence import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -9,6 +13,9 @@ import javax.inject.Singleton class PersistenceProvider @Inject constructor( @ApplicationContext private val context: Context ) { + // Long-lived scope for one-off maintenance that must outlive the caller's frame. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun openDatabase(entropy: String) { // Always delegate to init(): it is idempotent (no-op when this user's DB // is already open) and rebuilds when the entropy/DB name differs. We must @@ -17,9 +24,16 @@ class PersistenceProvider @Inject constructor( // signing in has to reach init() to swap to their own database. Guarding // on isOpen() here would hand the new user the previous user's data. FlipcashDatabase.init(context, entropy) + + // Drain any profiles the v25→v26 migration staged as raw JSON into the + // normalized user_profiles columns. Fire-and-forget so login isn't blocked; + // idempotent and cheap (only touches rows still carrying a blob), and reads + // already fall back to the staged blob, so this is best-effort cleanup. + val database = FlipcashDatabase.getInstance() ?: return + scope.launch { runCatching { database.backfillMigratedProfiles() } } } fun close() { FlipcashDatabase.closeDb() } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt index adb814559..20692a744 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt @@ -2,8 +2,9 @@ package com.flipcash.app.persistence.sources import androidx.paging.PagingSource import androidx.paging.PagingState +import androidx.room.withTransaction import com.flipcash.app.persistence.FlipcashDatabase -import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.entities.BlockedUserWithProfile import com.flipcash.app.persistence.sources.mapper.blocklist.BlockedUserEntityToProfileMapper import com.flipcash.app.persistence.sources.mapper.blocklist.BlockedUserToEntityMapper import com.flipcash.app.persistence.sources.mapper.blocklist.ResolvedBlockedUser @@ -23,23 +24,31 @@ class BlockedUserDataSource @Inject constructor( get() = FlipcashDatabase.getInstance() /** Room-backed paging source for the cached blocklist; empty until the DB is initialized. */ - fun observe(): PagingSource { - return db?.blockedUserDao()?.observePaged() ?: object : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - override suspend fun load(params: LoadParams): LoadResult = + fun observe(): PagingSource { + return db?.blockedUserDao()?.observePaged() ?: object : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null + override suspend fun load(params: LoadParams): LoadResult = LoadResult.Error(IllegalStateException("Database not initialized")) } } - fun toProfile(entity: BlockedUserEntity): BlockedUserProfile = toProfileMapper.map(entity) + fun toProfile(entity: BlockedUserWithProfile): BlockedUserProfile = toProfileMapper.map(entity) suspend fun upsert(resolved: List) { - db?.blockedUserDao()?.upsert(resolved.map { toEntityMapper.map(it) }) + val database = db ?: return + database.withTransaction { + database.blockedUserDao().upsert(resolved.map { toEntityMapper.map(it) }) + resolved.forEach { database.writeProfile(it) } + } } /** Atomically replaces the cached blocklist — a single Room invalidation, no empty flicker. */ suspend fun replaceAll(resolved: List) { - db?.blockedUserDao()?.replaceAll(resolved.map { toEntityMapper.map(it) }) + val database = db ?: return + database.withTransaction { + database.blockedUserDao().replaceAll(resolved.map { toEntityMapper.map(it) }) + resolved.forEach { database.writeProfile(it) } + } } suspend fun clear() { @@ -49,4 +58,17 @@ class BlockedUserDataSource @Inject constructor( suspend fun delete(userId: ID) { db?.blockedUserDao()?.delete(userId.hexEncodedString()) } + + /** + * Writes the blocked user's name + avatar into the shared `user_profiles` table without + * disturbing any richer profile already cached from a chat (the blocklist only resolves + * those two fields). See [com.flipcash.app.persistence.dao.UserProfileDao.upsertNameAndAvatar]. + */ + private suspend fun FlipcashDatabase.writeProfile(resolved: ResolvedBlockedUser) { + userProfileDao().upsertNameAndAvatar( + userIdHex = resolved.blocked.userId.hexEncodedString(), + displayName = resolved.profile?.displayName.orEmpty(), + profilePicture = resolved.profile?.profilePicture, + ) + } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt index c53d3b72c..c2ca96fdf 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -1,5 +1,6 @@ package com.flipcash.app.persistence.sources +import androidx.room.withTransaction import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper import com.flipcash.services.models.chat.ChatId @@ -28,7 +29,7 @@ class ChatMemberDataSource @Inject constructor( fun observeAll(): Flow>> = db?.chatMemberDao()?.observeAll()?.map { entities -> - entities.groupBy { it.chatIdHex } + entities.groupBy { it.member.chatIdHex } .mapValues { (_, members) -> members.map { mapper.toMember(it) } } } ?: emptyFlow() @@ -47,8 +48,15 @@ class ChatMemberDataSource @Inject constructor( db?.chatMemberDao()?.getMembersForChat(chatIdHex)?.map { mapper.toMember(it) } ?: emptyList() suspend fun upsert(chatId: ChatId, members: List) { + val database = db ?: return val hex = mapper.chatIdHex(chatId) - db?.chatMemberDao()?.upsert(members.map { mapper.toEntity(hex, it) }) + // Member rows and their normalized profiles are written in one transaction so a + // member never observes a missing profile mid-write. Profiles go first (the + // @Relation reads them), and use the authoritative full-profile upsert. + database.withTransaction { + database.userProfileDao().upsertFull(members.map { mapper.toProfileEntity(it) }) + database.chatMemberDao().upsert(members.map { mapper.toEntity(hex, it) }) + } } suspend fun updatePointers(chatId: ChatId, pointer: MessagePointer) { diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt index 1dbaa1b0b..2bfed1fb6 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt @@ -1,21 +1,26 @@ package com.flipcash.app.persistence.sources.mapper.blocklist import com.flipcash.app.core.blocklist.BlockedUserProfile -import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.entities.BlockedUserWithProfile +import com.flipcash.app.persistence.entities.toSerialized import com.getcode.opencode.model.core.ID import com.getcode.opencode.mapper.Mapper import javax.inject.Inject import kotlin.time.Instant class BlockedUserEntityToProfileMapper @Inject constructor() : - Mapper { + Mapper { - override fun map(from: BlockedUserEntity): BlockedUserProfile = BlockedUserProfile( - userId = from.userIdHex.hexToId(), - displayName = from.userProfileJson?.displayName.orEmpty(), - profilePicture = from.userProfileJson?.profilePicture, - blockedAt = Instant.fromEpochMilliseconds(from.blockedAtEpochMs), - ) + override fun map(from: BlockedUserWithProfile): BlockedUserProfile { + // The joined profile may be absent (not yet synced) — fall back to empty display data. + val profile = from.profile?.toSerialized() + return BlockedUserProfile( + userId = from.blocked.userIdHex.hexToId(), + displayName = profile?.displayName.orEmpty(), + profilePicture = profile?.profilePicture, + blockedAt = Instant.fromEpochMilliseconds(from.blocked.blockedAtEpochMs), + ) + } private fun String.hexToId(): ID { val data = ByteArray(length / 2) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserToEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserToEntityMapper.kt index 315ae9058..731c45fe5 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserToEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserToEntityMapper.kt @@ -1,6 +1,5 @@ package com.flipcash.app.persistence.sources.mapper.blocklist -import com.flipcash.app.persistence.converters.UserProfileSerialized import com.flipcash.app.persistence.entities.BlockedUserEntity import com.flipcash.services.models.BlockedUser import com.flipcash.services.models.UserProfile @@ -11,7 +10,9 @@ import javax.inject.Inject /** * A server blocklist entry paired with its separately-resolved display [profile]. The server only * returns [BlockedUser] (id + timestamp); the profile is fetched alongside so the row can be cached - * fully renderable. + * fully renderable. The profile is persisted separately in the shared `user_profiles` table (see + * [com.flipcash.app.persistence.sources.BlockedUserDataSource]); this mapper produces only the + * blocklist row itself. */ data class ResolvedBlockedUser( val blocked: BlockedUser, @@ -24,16 +25,5 @@ class BlockedUserToEntityMapper @Inject constructor() : override fun map(from: ResolvedBlockedUser): BlockedUserEntity = BlockedUserEntity( userIdHex = from.blocked.userId.hexEncodedString(), blockedAtEpochMs = from.blocked.blockedAt.toEpochMilliseconds(), - // Only the fields the blocklist row renders (name + avatar) are persisted; the rest of the - // profile is intentionally dropped. - userProfileJson = from.profile?.let { - UserProfileSerialized( - displayName = it.displayName, - socialAccounts = emptyList(), - phoneNumber = null, - email = null, - profilePicture = it.profilePicture, - ) - }, ) } 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 966773645..1188a8bcf 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 @@ -8,9 +8,12 @@ import com.flipcash.app.persistence.converters.ReactionSummarySerialized import com.flipcash.app.persistence.converters.SocialAccountSerialized import com.flipcash.app.persistence.converters.UserProfileSerialized import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMemberWithProfile import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.entities.ChatMetadataEntity import com.flipcash.app.persistence.entities.MessageStatus +import com.flipcash.app.persistence.entities.UserProfileEntity +import com.flipcash.app.persistence.entities.toSerialized import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId @@ -142,16 +145,34 @@ class ChatEntityMapper @Inject constructor() { return ChatMemberEntity( chatIdHex = chatIdHex, userIdHex = member.userId.hexEncodedString(), - userProfileJson = member.userProfile.toSerialized(), pointersJson = member.pointers.map { it.toSerialized() }, ) } - fun toMember(entity: ChatMemberEntity): ChatMember { + /** + * The member's profile, decomposed for the shared `user_profiles` table. Written + * alongside [toEntity] so a member row and its normalized profile stay in sync — the + * profile is authoritative (full) here, unlike the name+avatar-only blocklist write. + */ + fun toProfileEntity(member: ChatMember): UserProfileEntity { + val profile = member.userProfile + return UserProfileEntity( + userIdHex = member.userId.hexEncodedString(), + displayName = profile.displayName, + phoneValue = profile.phoneNumber?.value, + phoneVerified = profile.phoneNumber?.verified, + emailValue = profile.email?.value, + emailVerified = profile.email?.verified, + socialAccounts = profile.socialAccounts.map { it.toSerialized() }, + profilePicture = profile.profilePicture, + ) + } + + fun toMember(relation: ChatMemberWithProfile): ChatMember { return ChatMember( - userId = entity.userIdHex.hexToId(), - userProfile = entity.userProfileJson?.toDomain() ?: UserProfile.Empty, - pointers = entity.pointersJson?.map { it.toDomain() } ?: emptyList(), + userId = relation.member.userIdHex.hexToId(), + userProfile = relation.profile?.toSerialized()?.toDomain() ?: UserProfile.Empty, + pointers = relation.member.pointersJson?.map { it.toDomain() } ?: emptyList(), ) } @@ -270,14 +291,6 @@ private fun MessagePointerSerialized.toDomain(): MessagePointer = MessagePointer timestamp = Instant.fromEpochSeconds(timestampEpochSeconds), ) -private fun UserProfile.toSerialized(): UserProfileSerialized = UserProfileSerialized( - displayName = displayName, - socialAccounts = socialAccounts.map { it.toSerialized() }, - phoneNumber = phoneNumber, - email = email, - profilePicture = profilePicture, -) - private fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( displayName = displayName.orEmpty(), socialAccounts = socialAccounts.map { it.toDomain() }, diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/BlocklistRemoteMediator.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/BlocklistRemoteMediator.kt index 797d8c75d..c0216209b 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/BlocklistRemoteMediator.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/BlocklistRemoteMediator.kt @@ -4,7 +4,7 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator -import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.entities.BlockedUserWithProfile import com.flipcash.app.persistence.sources.BlockedUserDataSource import com.flipcash.app.persistence.sources.mapper.blocklist.ResolvedBlockedUser import com.flipcash.services.controllers.BlocklistController @@ -33,13 +33,13 @@ class BlocklistRemoteMediator( private val controller: BlocklistController, private val profileController: ProfileController, private val dataSource: BlockedUserDataSource, -) : RemoteMediator() { +) : RemoteMediator() { private var nextToken: PagingToken? = null override suspend fun load( loadType: LoadType, - state: PagingState, + state: PagingState, ): MediatorResult { return try { val token = when (loadType) {