From e3a38a251db395458229b4bdf7d0533304a14079 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 14:25:44 -0400 Subject: [PATCH 1/2] fix(activity): show Convert transactions in the feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A swap spans two mints, so the backend leaves `payment_amount` unset and carries both amounts in `additional_metadata` instead. `ActivityFeedMessageMapper` only ever read `payment_amount`, so every Convert cached with a null amount, rate, and mint — which meant it matched no token's activity query and rendered blank in the global feed. - Map multi-mint amounts from `additional_metadata` when `payment_amount` is absent. - Attribute a Convert to *both* mints, so it appears on the source and destination token screens (`MintInvolvement` + the metadata clause in `observeRecentForMint`). - Render it the way iOS does: overlapping dual-token avatar, from-amount on top, `- Fee` beneath. - Title the row with the two token names ("Moony -> Jeffy") rather than the server's bare "Converted", falling back to the server text until both tokens resolve. Rows cached before this can't be repaired in place (the amounts were never persisted) and can't be re-fetched either, since sync only walks forward from the newest cached id. DB 28 -> 29 clears `messages` so the next sync re-seeds from scratch — the same approach as the existing 1->2, 6->7, 7->8 and 9->10 migrations. Also fixes a silent data-loss path found alongside it: `MessageDataSource.upsert` no-oped when the per-user DB wasn't open yet, discarding a whole fetched page while the coordinator still reported `Synced`. Because every sync path pages forward from the newest cached id, those notifications were never requested again. It now fails loudly (matching `observe()`, which already errors on a missing DB), the mediator turns that into a retryable error, and the coordinator only reports `Synced` once the write has actually landed. --- .../app/core/feed/ActivityFeedMessage.kt | 9 +- .../internal/WalletLoadingStateTest.kt | 1 + .../29.json | 756 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 21 +- .../app/persistence/dao/MessageDao.kt | 19 +- .../app/persistence/dao/MessageDaoTest.kt | 73 ++ .../persistence/sources/MessageDataSource.kt | 13 +- .../sources/MessageDataSourceTest.kt | 45 ++ .../ActivityFeedCoordinator.kt | 40 +- .../transactionhistory/ActivityFeedRow.kt | 79 +- .../transactionhistory/MintInvolvement.kt | 27 + .../transactionhistory/TransactionListItem.kt | 8 + .../internal/TransactionItemMapper.kt | 25 +- .../src/main/res/values/strings.xml | 4 + .../transactionhistory/MintInvolvementTest.kt | 85 ++ .../TransactionItemMapperTest.kt | 70 ++ .../domain/ActivityFeedMessageMapper.kt | 16 +- .../domain/ActivityFeedMessageMapperTest.kt | 93 +++ 18 files changed, 1362 insertions(+), 22 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/29.json create mode 100644 apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/MessageDataSourceTest.kt create mode 100644 apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/MintInvolvement.kt create mode 100644 apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MintInvolvementTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt index d5b108cd57..697db2c7cf 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt @@ -9,9 +9,16 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlin.time.Instant +/** + * A feed entry paired with the token metadata its row needs. + * + * [token] is the entry's own token (for a convert, the source side). [toToken] is only set for a + * convert, which spans two mints and draws both logos; everything else leaves it null. + */ data class ActivityFeedMessageWithToken( val message: ActivityFeedMessage, - val token: Token? + val token: Token?, + val toToken: Token? = null, ) data class ActivityFeedMessage( diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt index 6668c8008c..87950c6959 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt @@ -28,6 +28,7 @@ class WalletLoadingStateTest { avatar = TransactionAvatar.Generic, signedAmountPrefix = "+", amount = null, + fee = null, canCancel = false, ) diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/29.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/29.json new file mode 100644 index 0000000000..2d54482213 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/29.json @@ -0,0 +1,756 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "8ce3e8bbaa9d3503813ba3096c484e48", + "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, 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, '8ce3e8bbaa9d3503813ba3096c484e48')" + ] + } +} \ 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 d2237c009a..d31fc5aeaa 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 @@ -87,8 +87,9 @@ import com.getcode.utils.subByteArray // needs data movement an AutoMigration can't express. AutoMigration(from = 26, to = 27), // messages.text_substitutions (nullable) AutoMigration(from = 27, to = 28), // tokens.market_cap_metrics (nullable) + AutoMigration(from = 28, to = 29, spec = FlipcashDatabase.Migration28To29::class), ], - version = 28, + version = 29, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -187,6 +188,24 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + /** + * Data-only migration: swap ("Converted") notifications cached before multi-mint amounts were + * mapped carry NULL financials — no amount, rate, or mint — because a swap leaves + * `payment_amount` unset and puts its amounts in `additional_metadata`. Those rows can't be + * repaired in place (the data was never persisted) and they can't be re-fetched either: + * `ActivityFeedCoordinator.fetchSinceLatest` only ever walks *forward* from the newest cached + * id, so anything already cached is never requested again. + * + * Clearing the cache is the repair. The next sync sees an empty table, falls into its + * descending re-seed, and re-fetches the recent feed from scratch; paging refills older + * history on demand. + */ + class Migration28To29 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("DELETE FROM messages") + } + } + companion object { /** diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt index d4b6d8ecae..ade4b0fe43 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt @@ -40,8 +40,25 @@ interface MessageDao { /** * The [limit] most recent messages for a single token, newest first — the token info screen's * per-token recent-activity preview. + * + * A convert spans two mints and belongs to both token histories, but a row carries only one + * [MessageEntity.mintBase58] — the source mint, taken from the amount the user gave up. The + * destination mint is only ever written into the serialized metadata (`SwappedCryptoMetadata` + * persists it as a base58 string under `toMint`), so matching it there is what puts the convert + * on the *receiving* token's screen too. Matching on the JSON rather than adding a column also + * fixes rows that are already in the cache. Same LIKE-on-metadata approach as + * [hasEverReceivedMoney]; base58 contains no LIKE wildcards, so the bound value is literal. + * + * The destination match is restricted to converts on purpose: a withdrawal can also carry swap + * metadata, but there the destination leaves the app entirely, so it does not belong on the + * destination token's history. `ActivityFeedCoordinator.involves` applies the same rule in memory. */ - @Query("SELECT * FROM messages WHERE mintBase58 = :mintBase58 ORDER BY timestamp DESC LIMIT :limit") + @Query( + "SELECT * FROM messages WHERE mintBase58 = :mintBase58 " + + "OR (metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.SwappedCrypto%' " + + "AND metadata LIKE '%\"toMint\":\"' || :mintBase58 || '\"%') " + + "ORDER BY timestamp DESC LIMIT :limit" + ) fun observeRecentForMint(mintBase58: String, limit: Int): Flow> @Query("SELECT * FROM messages") diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/MessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/MessageDaoTest.kt index cbf5604dfd..b2d0580416 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/MessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/MessageDaoTest.kt @@ -12,6 +12,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -63,6 +64,47 @@ class MessageDaoTest { mintBase58 = null, ) + /** + * A convert row as it is actually persisted: `mintBase58` is the *source* mint (the side the + * feed amount comes from) and the destination mint lives only inside the metadata JSON. + */ + private fun swapMessage(source: String, destination: String) = MessageEntity( + idBase58 = "message${++nextId}", + text = "Converted", + amountUsdc = 100L, + amountNative = 100L, + nativeCurrency = "usd", + rate = 1.0, + state = "COMPLETED", + timestamp = nextId.toLong(), + metadata = """{"type":"com.flipcash.app.core.feed.MessageMetadata.SwappedCrypto",""" + + """"swap":{"from":{"underlyingTokenAmount":{"quarks":100},""" + + """"nativeAmount":{"quarks":100},"rate":{"fx":1.0,"currency":"usd"},"mint":"$source"},""" + + """"toMint":"$destination","toAmount":null,"fee":{"quarks":10},"swapState":"SUCCEEDED"}}""", + mintBase58 = source, + ) + + /** A withdrawal executed as a swap: the destination leaves the app, so it is not a convert. */ + private fun withdrawViaSwapMessage(source: String, destination: String) = + swapMessage(source, destination).let { + it.copy( + text = "Withdrew", + metadata = it.metadata!!.replace( + "MessageMetadata.SwappedCrypto\",\"swap\"", + "MessageMetadata.WithdrewCrypto\",\"swapMetadata\"", + ), + ) + } + + private suspend fun recentForMint(mint: String): List { + var result = emptyList() + dao.observeRecentForMint(mint, limit = 10).test { + result = awaitItem() + cancelAndIgnoreRemainingEvents() + } + return result + } + private suspend fun hasReceivedMoney(): Boolean { var result = false dao.hasEverReceivedMoney().test { @@ -125,4 +167,35 @@ class MessageDaoTest { cancelAndIgnoreRemainingEvents() } } + + // -- per-token activity -- + + private val sourceMint = "5AMAA9JV9H97YYVxx8F6FsCMmTwXSuTTQneiup4RYAUQ" + private val destinationMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + private val unrelatedMint = "So11111111111111111111111111111111111111112" + + @Test + fun `a convert shows on the source token's activity`() = runTest { + dao.upsert(swapMessage(source = sourceMint, destination = destinationMint)) + assertEquals(1, recentForMint(sourceMint).size) + } + + @Test + fun `a convert shows on the destination token's activity`() = runTest { + dao.upsert(swapMessage(source = sourceMint, destination = destinationMint)) + assertEquals(1, recentForMint(destinationMint).size) + } + + @Test + fun `a convert stays off an unrelated token's activity`() = runTest { + dao.upsert(swapMessage(source = sourceMint, destination = destinationMint)) + assertEquals(0, recentForMint(unrelatedMint).size) + } + + @Test + fun `a withdrawal executed as a swap stays off the destination token's activity`() = runTest { + dao.upsert(withdrawViaSwapMessage(source = sourceMint, destination = destinationMint)) + assertEquals(0, recentForMint(destinationMint).size) + assertEquals(1, recentForMint(sourceMint).size) + } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt index 045b6e3ae7..07ca827756 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt @@ -55,9 +55,20 @@ class MessageDataSource @Inject constructor( db?.messageDao()?.deleteAllMessages() } + /** + * Persists a fetched page, failing loudly when the per-user DB isn't open. + * + * A page that can't be written is a *failed* fetch, not an empty one: every sync path pages + * forward from the newest cached id and never re-requests notifications it believes are already + * cached, so a silently-dropped page is lost for the life of the cache. Throwing lets + * `FeedRemoteMediator` return a retryable `MediatorResult.Error` and keeps + * `ActivityFeedCoordinator` from reporting a sync that never landed — matching [observe], which + * already treats a missing DB as an error rather than as no results. + */ override suspend fun upsert(value: List) { + val dao = checkNotNull(db?.messageDao()) { "Database not initialized" } val entities = notificationEntityMapper.map(value) - db?.messageDao()?.upsert(*entities.toTypedArray()) + dao.upsert(*entities.toTypedArray()) } /** diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/MessageDataSourceTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/MessageDataSourceTest.kt new file mode 100644 index 0000000000..a71238d8d8 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/MessageDataSourceTest.kt @@ -0,0 +1,45 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.sources.mapper.notifications.MessageEntityToFeedMessageMapper +import com.flipcash.app.persistence.sources.mapper.notifications.MetadataMapper +import com.flipcash.app.persistence.sources.mapper.notifications.NotificationToEntityMapper +import com.flipcash.app.persistence.sources.mapper.notifications.SingleNotificationToEntityMapper +import com.flipcash.services.models.ActivityFeedNotification +import com.flipcash.services.models.NotificationMetadata +import com.flipcash.services.models.NotificationState +import kotlin.time.Instant +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * The per-user DB is created at login, so a write can always be attempted before it exists. Skipping + * the write in that window used to lose the page permanently: every sync path walks *forward* from + * the newest cached id, so notifications that never landed are never requested again. + */ +class MessageDataSourceTest { + + private val dataSource = MessageDataSource( + messageEntityMapper = MessageEntityToFeedMessageMapper(), + notificationEntityMapper = NotificationToEntityMapper( + SingleNotificationToEntityMapper(MetadataMapper()) + ), + ) + + @Test + fun `upsert fails loudly instead of dropping the page when the database is not open`() { + assertThrows(IllegalStateException::class.java) { + runBlocking { dataSource.upsert(listOf(notification())) } + } + } + + private fun notification() = ActivityFeedNotification( + id = listOf(0x01, 0x02, 0x03), + text = "Converted", + amount = null, + timestamp = Instant.fromEpochSeconds(1700000000L), + state = NotificationState.COMPLETED, + metadata = NotificationMetadata.Unknown, + textSubstitutions = emptyList(), + ) +} diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt index 228e97b931..fb949733db 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt @@ -139,10 +139,13 @@ class ActivityFeedCoordinator @Inject internal constructor( @OptIn(ExperimentalCoroutinesApi::class) fun transactions(mint: Mint): Flow> = messages.map { page -> page.filter { message -> - message.amount?.mint == mint + // Converts span two mints and belong on both token histories (see [involves]). + message.involves(mint) }.map { msg -> val result = msg.amount?.mint?.let { tokenProvider.getTokenMetadata(it) }?.getOrNull() - ActivityFeedMessageWithToken(msg, result?.token) + val destination = convertOf(msg.metadata)?.toMint + ?.let { tokenProvider.getTokenMetadata(Mint(it.bytes)) }?.getOrNull() + ActivityFeedMessageWithToken(msg, result?.token, destination?.token) } } @@ -181,7 +184,10 @@ class ActivityFeedCoordinator @Inject internal constructor( ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } ?.let(::ensureProfile) val token = msg.amount?.mint?.let { tokens[it] } - transactionItemMapper.map(ActivityFeedMessageWithToken(msg, token) to profiles) + val toToken = destinationTokenOf(msg.metadata, tokens) + transactionItemMapper.map( + ActivityFeedMessageWithToken(msg, token, toToken) to profiles + ) } } @@ -212,6 +218,14 @@ class ActivityFeedCoordinator @Inject internal constructor( else -> null } + /** + * The destination token of a convert, cache-only (null for everything else, and for a mint whose + * metadata hasn't landed yet — the row re-maps when it does). Re-keyed as a [Mint] because the + * persisted destination is a plain `PublicKey` and `KeyType.equals` is javaClass-sensitive. + */ + private fun destinationTokenOf(meta: MessageMetadata?, tokens: Map): Token? = + convertOf(meta)?.toMint?.let { tokens[Mint(it.bytes)] } + private fun counterpartyOf(meta: NotificationMetadata?): ID? = when (meta) { is NotificationMetadata.DirectlySentCrypto -> meta.userId is NotificationMetadata.ReceivedCrypto -> meta.userId @@ -232,7 +246,10 @@ class ActivityFeedCoordinator @Inject internal constructor( ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } ?.let(::ensureProfile) val token = msg.amount?.mint?.let { tokens[it] } - transactionItemMapper.map(ActivityFeedMessageWithToken(msg, token) to profiles) + val toToken = destinationTokenOf(msg.metadata, tokens) + transactionItemMapper.map( + ActivityFeedMessageWithToken(msg, token, toToken) to profiles + ) } } @@ -249,7 +266,10 @@ class ActivityFeedCoordinator @Inject internal constructor( ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } ?.let(::ensureProfile) val token = msg.amount?.mint?.let { tokens[it] } - transactionItemMapper.map(ActivityFeedMessageWithToken(msg, token) to profiles) + val toToken = destinationTokenOf(msg.metadata, tokens) + transactionItemMapper.map( + ActivityFeedMessageWithToken(msg, token, toToken) to profiles + ) } } @@ -282,7 +302,7 @@ class ActivityFeedCoordinator @Inject internal constructor( } return activityFeedController.getNotificationsById(pendingMessages.map { it.id }) - .map { notifications -> + .mapCatching { notifications -> val completedCount = notifications.count { it.state == NotificationState.COMPLETED } trace( @@ -306,10 +326,11 @@ class ActivityFeedCoordinator @Inject internal constructor( descending = latest == null, ) ) + // Caching is part of the fetch, so a write that fails fails the whole refresh: this + // only ever pages *forward* from the newest cached id, so a page that doesn't land is + // never asked for again. + .mapCatching { dataSource.upsert(it) } .onSuccess { - dataSource.upsert(it) - // The per-user DB is opened before the auth gate that lets this call run at all, so - // a successful fetch here really has landed in the cache. _syncState.value = FeedSyncState.Synced } // Don't downgrade a sync that already succeeded — a later failure just means this @@ -319,6 +340,5 @@ class ActivityFeedCoordinator @Inject internal constructor( if (current == FeedSyncState.Unknown) FeedSyncState.Unavailable else current } } - .map { Unit } } } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt index 78c8e7cee2..380a861c35 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt @@ -1,7 +1,9 @@ package com.flipcash.shared.transactionhistory import android.text.format.DateFormat +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.flipcash.app.core.ui.TokenIcon +import com.getcode.opencode.model.financial.Token import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.services.models.UserProfile import com.getcode.theme.CodeTheme @@ -54,6 +57,8 @@ fun ActivityFeedRow( ContactAvatar(userProfile = a.profile, modifier = avatarModifier) is TransactionAvatar.TokenIcon -> TokenIcon(token = a.token, modifier = avatarModifier) + is TransactionAvatar.SwapTokens -> + SwapAvatar(a, modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x8)) TransactionAvatar.Generic -> ContactAvatar(userProfile = UserProfile.Empty, modifier = avatarModifier) } @@ -76,16 +81,78 @@ fun ActivityFeedRow( ) } - item.amount?.let { fiat -> - Text( - text = fiat.formatted(extraPrefix = item.signedAmountPrefix?.ifEmpty { null }), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + val fee = item.fee + val amount = item.amount + if (fee != null && amount != null) { + // A convert is two amounts in one row: what left the source token, and what the swap + // cost. It carries no sign — the pairing, not a prefix, is what reads as an exchange. + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = amount.formatted(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) + Text( + text = stringResource(R.string.label_activity_convertFee, fee.formatted()), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } else { + amount?.let { fiat -> + Text( + text = fiat.formatted(extraPrefix = item.signedAmountPrefix?.ifEmpty { null }), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } } } } +/** + * A convert's two tokens as overlapping coins — the destination sits over the source, ringed in the + * page background so the overlap reads as depth. Mirrors iOS's `swapAvatar` in `ActivityRow.swift`. + */ +@Composable +private fun SwapAvatar( + avatar: TransactionAvatar.SwapTokens, + modifier: Modifier = Modifier, +) { + val coin = CodeTheme.dimens.staticGrid.x5 + Box(modifier = modifier) { + TokenCoin( + token = avatar.from, + modifier = Modifier + .align(Alignment.TopStart) + .requiredSize(coin), + ) + TokenCoin( + token = avatar.to, + modifier = Modifier + .align(Alignment.BottomEnd) + .requiredSize(coin) + .border(CodeTheme.dimens.thickBorder, CodeTheme.colors.background, CircleShape), + ) + } +} + +/** One coin of a [SwapAvatar]; an unresolved side draws the shared placeholder. */ +@Composable +private fun TokenCoin(token: Token?, modifier: Modifier) { + val shaped = modifier.clip(CircleShape) + if (token != null) { + TokenIcon(token = token, modifier = shaped) + } else { + TokenIcon(image = null, modifier = shaped) + } +} + /** * Returns a human-readable relative timestamp, matching the behaviour of * `formatLastActivity` in `:shared:chat-ui` (but without depending on that module, diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/MintInvolvement.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/MintInvolvement.kt new file mode 100644 index 0000000000..bfbfccaecf --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/MintInvolvement.kt @@ -0,0 +1,27 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.app.core.feed.ActivityFeedMessage +import com.flipcash.app.core.feed.MessageMetadata +import com.flipcash.app.core.feed.SwappedCryptoMetadata +import com.getcode.solana.keys.Mint + +/** + * Whether this entry belongs on [mint]'s activity history. + * + * Usually that is just the entry's own mint. A convert is the exception: it moves value out of one + * token and into another, so it shows on both. Its amount carries the source mint; the destination + * lives in the swap metadata. + * + * A withdrawal can also carry swap metadata, but there the destination leaves the app, so it counts + * only against the source. Kept in sync with `MessageDao.observeRecentForMint`, which applies the + * same rule in SQL for the non-paged previews. + * + * Compared by bytes: `toMint` is a plain `PublicKey` while the filter is a [Mint], and + * `KeyType.equals` is javaClass-sensitive, so the two never compare equal as objects. + */ +internal fun ActivityFeedMessage.involves(mint: Mint): Boolean = + amount?.mint == mint || convertOf(metadata)?.toMint?.bytes == mint.bytes + +/** The swap of a *convert*, specifically — not any entry that happens to carry swap metadata. */ +internal fun convertOf(meta: MessageMetadata?): SwappedCryptoMetadata? = + (meta as? MessageMetadata.SwappedCrypto)?.swap diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt index 8d2c2b3f84..0a960de3f9 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt @@ -9,11 +9,18 @@ import kotlin.time.Instant * Leading avatar for a transaction row. * - [Profile] — a resolved counterparty (tip / user-to-user send-receive), keyed by user id. * - [TokenIcon] — no counterparty (deposit / buy / sell / withdraw): the token's icon. + * - [SwapTokens] — a convert: both sides' icons, source behind destination. * - [Generic] — unresolved / unknown counterparty. */ sealed interface TransactionAvatar { data class Profile(val profile: UserProfile) : TransactionAvatar data class TokenIcon(val token: Token) : TransactionAvatar + + /** + * Either side may still be unresolved (its metadata hasn't landed in the token cache yet); the + * row draws a placeholder for a missing one rather than switching avatar shape mid-hydration. + */ + data class SwapTokens(val from: Token?, val to: Token?) : TransactionAvatar data object Generic : TransactionAvatar } @@ -24,5 +31,6 @@ data class TransactionListItem( val avatar: TransactionAvatar, val signedAmountPrefix: String?, // "-", "+", or null (for metadata == null) val amount: Fiat?, // message.amount.nativeAmount, or null if non-financial + val fee: Fiat?, // converts only: what the swap cost, shown under the amount val canCancel: Boolean, ) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt index 1a162f685a..bdfa882ed3 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -7,6 +7,7 @@ import com.flipcash.services.models.UserProfile import com.flipcash.shared.transactionhistory.R import com.flipcash.shared.transactionhistory.TransactionAvatar import com.flipcash.shared.transactionhistory.TransactionListItem +import com.flipcash.shared.transactionhistory.convertOf import com.getcode.opencode.mapper.Mapper import com.getcode.opencode.model.core.ID import com.getcode.util.resources.ResourceHelper @@ -29,8 +30,11 @@ internal class TransactionItemMapper @Inject constructor( val token = source.token val counterparty = userIdOf(meta)?.let { profiles[it.hexEncodedString()] } + val convert = convertOf(meta) val avatar: TransactionAvatar = when { counterparty != null -> TransactionAvatar.Profile(counterparty) + // A convert always draws both sides, even before both tokens have resolved. + convert != null -> TransactionAvatar.SwapTokens(from = token, to = source.toToken) hasNoCounterparty(meta) && token != null -> TransactionAvatar.TokenIcon(token) else -> TransactionAvatar.Generic } @@ -47,16 +51,35 @@ internal class TransactionItemMapper @Inject constructor( // row to the literal key "null". Duplicate keys wedge the LazyColumn under the app's // SharedTransitionLayout lookahead (whole-app freeze as a duplicate-keyed row scrolls in). id = msg.id.hexEncodedString(), - title = resolveTitle(resources, meta, msg.text, msg.textSubstitutions, counterparty, profiles), + title = convertTitle(resources, source) + ?: resolveTitle(resources, meta, msg.text, msg.textSubstitutions, counterparty, profiles), timestamp = msg.timestamp, avatar = avatar, signedAmountPrefix = prefix, amount = msg.amount?.nativeAmount, + fee = convert?.fee, canCancel = (meta as? MessageMetadata.IndirectlySentCrypto)?.canCancel == true, ) } } +/** + * A convert spans two mints, so its row reads as the exchange itself — "USDF → Dad Cash" — rather + * than the server's bare verb ("Converted"), which says nothing about either side. + * + * Returns null for anything that isn't a convert, and for a convert whose tokens haven't both + * resolved yet: token metadata arrives reactively, so the server text shows until it lands. + */ +private fun convertTitle( + resources: ResourceHelper, + source: ActivityFeedMessageWithToken, +): String? { + convertOf(source.message.metadata) ?: return null + val from = source.token?.name?.takeIf { it.isNotBlank() } ?: return null + val to = source.toToken?.name?.takeIf { it.isNotBlank() } ?: return null + return resources.getString(R.string.title_activity_convert, from, to) +} + /** * Resolves the row title. * diff --git a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml index adc370f5d7..e237f5f3fc 100644 --- a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml @@ -2,4 +2,8 @@ Tip from %1$s + + %1$s → %2$s + + -%1$s Fee diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MintInvolvementTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MintInvolvementTest.kt new file mode 100644 index 0000000000..90321ff91e --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MintInvolvementTest.kt @@ -0,0 +1,85 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.app.core.feed.ActivityFeedMessage +import com.flipcash.app.core.feed.MessageMetadata +import com.flipcash.app.core.feed.MessageState +import com.flipcash.app.core.feed.SwapState +import com.flipcash.app.core.feed.SwappedCryptoMetadata +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import kotlin.time.Instant +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A convert spans two mints and belongs on both token histories. Mirrors the SQL rule in + * `MessageDao.observeRecentForMint`. + */ +class MintInvolvementTest { + + private val source = Mint.usdf + private val destination = Mint.usdc + private val unrelated = Mint("So11111111111111111111111111111111111111112") + + private fun localFiat(mint: Mint) = LocalFiat( + usdf = Fiat(20.0, CurrencyCode.USD), + nativeAmount = Fiat(20.0, CurrencyCode.USD), + mint = mint, + ) + + private fun swap(from: Mint, to: Mint) = SwappedCryptoMetadata( + from = localFiat(from), + toMint = PublicKey(to.bytes), + toAmount = localFiat(to), + fee = Fiat(0.25, CurrencyCode.USD), + swapState = SwapState.SUCCEEDED, + ) + + private fun message(metadata: MessageMetadata?, mint: Mint) = ActivityFeedMessage( + id = listOf(0x01, 0x02, 0x03).map { it.toByte() }, + text = "Converted", + amount = localFiat(mint), + timestamp = Instant.fromEpochSeconds(1700000000L), + state = MessageState.COMPLETED, + metadata = metadata, + ) + + @Test + fun `a convert belongs to the source token`() { + val msg = message(MessageMetadata.SwappedCrypto(swap(source, destination)), mint = source) + assertTrue(msg.involves(source)) + } + + @Test + fun `a convert belongs to the destination token`() { + val msg = message(MessageMetadata.SwappedCrypto(swap(source, destination)), mint = source) + assertTrue(msg.involves(destination)) + } + + @Test + fun `a convert does not belong to an unrelated token`() { + val msg = message(MessageMetadata.SwappedCrypto(swap(source, destination)), mint = source) + assertFalse(msg.involves(unrelated)) + } + + @Test + fun `a plain entry belongs only to its own token`() { + val msg = message(MessageMetadata.DepositedCrypto, mint = source) + assertTrue(msg.involves(source)) + assertFalse(msg.involves(destination)) + } + + @Test + fun `a withdrawal executed as a swap belongs only to the source token`() { + val msg = message( + MessageMetadata.WithdrewCrypto(swapMetadata = swap(source, destination)), + mint = source, + ) + assertTrue(msg.involves(source)) + assertFalse(msg.involves(destination)) + } +} diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt index 0f72190ba9..e1a8e0c96c 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt @@ -5,6 +5,8 @@ import com.flipcash.app.core.feed.ActivityFeedMessageWithToken import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.core.feed.MessageState import com.flipcash.app.core.feed.MessageSubstitution +import com.flipcash.app.core.feed.SwapState +import com.flipcash.app.core.feed.SwappedCryptoMetadata import com.flipcash.services.models.UserProfile import com.flipcash.shared.transactionhistory.internal.TransactionItemMapper import com.getcode.opencode.model.core.ID @@ -27,6 +29,7 @@ class TransactionItemMapperTest { private val resources = FakeResourceHelper() .stub(R.string.title_activity_tipFrom, "Tip from %1\$s") + .stub(R.string.title_activity_convert, "%1\$s → %2\$s") private val mapper = TransactionItemMapper(resources) private val knownUserId: ID = listOf(0x0A, 0x0B, 0x0C) @@ -218,4 +221,71 @@ class TransactionItemMapperTest { assertEquals(true, item.canCancel) assertEquals("-", item.signedAmountPrefix) } + + // -- converts (two mints, one row) -- + + private fun convert(fee: Double = 0.25) = MessageMetadata.SwappedCrypto( + swap = SwappedCryptoMetadata( + from = LocalFiat( + usdf = Fiat(20.0, CurrencyCode.USD), + nativeAmount = Fiat(20.0, CurrencyCode.USD), + ), + toMint = Mint.usdc, + toAmount = LocalFiat( + usdf = Fiat(19.75, CurrencyCode.USD), + nativeAmount = Fiat(19.75, CurrencyCode.USD), + mint = Mint.usdc, + ), + fee = Fiat(fee, CurrencyCode.USD), + swapState = SwapState.SUCCEEDED, + ), + ) + + @Test + fun `a convert shows both token logos and carries the fee`() { + val from = usdfToken() + val to = token(address = Mint.usdc, name = "Dad Cash", symbol = "DADCASH") + val msg = feedMessage(metadata = convert()).copy(text = "Converted", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, from, to) to emptyMap()) + + assertEquals(TransactionAvatar.SwapTokens(from, to), item.avatar) + assertEquals(Fiat(0.25, CurrencyCode.USD), item.fee) + assertEquals("-", item.signedAmountPrefix) + } + + @Test + fun `a convert titles itself with both token names instead of the server verb`() { + val from = usdfToken() + val to = token(address = Mint.usdc, name = "Dad Cash", symbol = "DADCASH") + val msg = feedMessage(metadata = convert()).copy(text = "Converted", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, from, to) to emptyMap()) + + assertEquals("USDF → Dad Cash", item.title) + } + + @Test + fun `a convert keeps the server title until both tokens resolve`() { + val from = usdfToken() + val msg = feedMessage(metadata = convert()).copy(text = "Converted", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, from, toToken = null) to emptyMap()) + + assertEquals("Converted", item.title) + } + + @Test + fun `a convert keeps the dual avatar while a token is still unresolved`() { + val from = usdfToken() + val msg = feedMessage(metadata = convert()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, from, toToken = null) to emptyMap()) + + assertEquals(TransactionAvatar.SwapTokens(from, null), item.avatar) + } + + @Test + fun `a non-convert carries no fee`() { + val msg = feedMessage(metadata = MessageMetadata.DepositedCrypto) + val item = mapper.map(ActivityFeedMessageWithToken(msg, usdfToken()) to emptyMap()) + + assertEquals(null, item.fee) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt index 336e25f47e..e609ab0436 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt @@ -32,7 +32,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( return ActivityFeedNotification( id = from.id.toId(), text = from.localizedText, - amount = from.paymentAmountOrNull?.let { localFiatOf(it) }, + amount = from.paymentAmountOrNull?.let { localFiatOf(it) } ?: from.multiMintAmount(), timestamp = Instant.fromEpochSeconds(from.ts.seconds), state = when (from.state) { Model.NotificationState.NOTIFICATION_STATE_PENDING -> NotificationState.PENDING @@ -85,6 +85,20 @@ internal class ActivityFeedMessageMapper @Inject constructor( } } +/** + * The feed amount for a multi-mint operation. + * + * Swaps span two mints, so the server leaves the single-mint `payment_amount` unset and carries the + * amounts in `additional_metadata` instead (see `activity/v1/model.proto`). The feed shows what the + * user gave up, so the source (`from`) side is the row's amount — and, just as importantly, its mint, + * which is what per-token surfaces filter on. + */ +private fun Model.Notification.multiMintAmount(): LocalFiat? = + when (additionalMetadataCase) { + Model.Notification.AdditionalMetadataCase.SWAPPED_CRYPTO -> localFiatOf(swappedCrypto.from) + else -> null + } + /** * Builds a [LocalFiat] from a proto [Common.CryptoPaymentAmount]: usdf (or no mint) collapses to a * plain localized Fiat; any other mint carries its own token amount, mint, and derived rate. diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt index d79bbb3768..f70019ce3a 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt @@ -17,6 +17,8 @@ import com.codeinc.flipcash.gen.common.v1.publicKey import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.NotificationState import com.flipcash.services.models.SwapState +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 import com.google.protobuf.ByteString import com.google.protobuf.Timestamp import org.junit.Test @@ -300,4 +302,95 @@ class ActivityFeedMessageMapperTest { val result = mapper.map(proto) assertNotNull(result.amount) } + + // --- Swap amounts (see model.proto: multi-mint operations carry amounts in additional_metadata) --- + + @Test + fun `an executed swap reports the source side as its amount`() { + val sourceMintBytes = ByteArray(32) { 7 } + val proto = baseNotification { + swappedCrypto = swappedCryptoNotificationMetadata { + from = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 5.0 + quarks = 5_000_000L + mint = publicKey { value = ByteString.copyFrom(sourceMintBytes) } + } + toAmount = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 4.9 + quarks = 4_900_000L + mint = publicKey { value = ByteString.copyFrom(ByteArray(32) { 9 }) } + } + fee = fiatPaymentAmount { + currency = "USD" + nativeAmount = 0.1 + } + swapState = Model.SwapState.SWAP_STATE_SUCCEEDED + } + } + + val amount = mapper.map(proto).amount + + assertNotNull(amount) + assertEquals(5_000_000L, amount.underlyingTokenAmount.quarks) + assertEquals(5.0, amount.nativeAmount.decimalValue, 0.01) + assertEquals(Mint(sourceMintBytes.toList()).base58(), amount.mint.base58()) + } + + @Test + fun `a pending swap reports the source side as its amount`() { + val sourceMintBytes = ByteArray(32) { 7 } + val proto = baseNotification { + swappedCrypto = swappedCryptoNotificationMetadata { + from = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 5.0 + quarks = 5_000_000L + mint = publicKey { value = ByteString.copyFrom(sourceMintBytes) } + } + toMint = publicKey { value = ByteString.copyFrom(ByteArray(32) { 9 }) } + fee = fiatPaymentAmount { + currency = "USD" + nativeAmount = 0.1 + } + swapState = Model.SwapState.SWAP_STATE_PENDING + } + } + + val amount = mapper.map(proto).amount + + assertNotNull(amount) + assertEquals(5_000_000L, amount.underlyingTokenAmount.quarks) + assertEquals(Mint(sourceMintBytes.toList()).base58(), amount.mint.base58()) + } + + @Test + fun `an explicit payment amount still wins over the swap metadata`() { + val proto = baseNotification { + paymentAmount = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 12.0 + quarks = 12_000_000L + } + swappedCrypto = swappedCryptoNotificationMetadata { + from = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 5.0 + quarks = 5_000_000L + } + toMint = publicKey { value = ByteString.copyFrom(ByteArray(32) { 9 }) } + fee = fiatPaymentAmount { + currency = "USD" + nativeAmount = 0.1 + } + swapState = Model.SwapState.SWAP_STATE_PENDING + } + } + + val amount = mapper.map(proto).amount + + assertNotNull(amount) + assertEquals(12_000_000L, amount.underlyingTokenAmount.quarks) + } } From bcf574c7aa27bc3398dee3ed37bff9792fedc7d2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 15:32:13 -0400 Subject: [PATCH 2/2] test(persistence): cover the v29 feed-clearing migration Folds in the standalone migration work from #1284, which duplicated the 28 -> 29 clear this branch already carries. Adds TransactionHistoryMigrationTest, following the hand-rolled pattern in UserProfileMigrationTest: asserts the feed is emptied, that a neighbouring table survives, and that it's a no-op on an already-empty feed. Also switches Migration28To29 to the onPostMigrate(SQLiteConnection) overload. Room's SupportSQLiteDatabase variant only fires when the database is built without a driver, so a future setDriver would turn the delete into a silent no-op. --- .../app/persistence/FlipcashDatabase.kt | 10 ++- .../TransactionHistoryMigrationTest.kt | 85 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/TransactionHistoryMigrationTest.kt 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 d31fc5aeaa..bbbcc16bd6 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 @@ -10,7 +10,9 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.execSQL import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -199,10 +201,14 @@ abstract class FlipcashDatabase : RoomDatabase() { * Clearing the cache is the repair. The next sync sees an empty table, falls into its * descending re-seed, and re-fetches the recent feed from scratch; paging refills older * history on demand. + * + * Takes the [SQLiteConnection] overload rather than the [SupportSQLiteDatabase] one used by + * [Migration22To23]: the latter only fires when Room is built without a driver, so a future + * `setDriver` would turn the delete into a silent no-op. */ class Migration28To29 : AutoMigrationSpec { - override fun onPostMigrate(db: SupportSQLiteDatabase) { - db.execSQL("DELETE FROM messages") + override fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL("DELETE FROM messages") } } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/TransactionHistoryMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/TransactionHistoryMigrationTest.kt new file mode 100644 index 0000000000..de0b3ea23c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/TransactionHistoryMigrationTest.kt @@ -0,0 +1,85 @@ +package com.flipcash.app.persistence + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteConnection +import androidx.test.core.app.ApplicationProvider +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * Covers the v29 upgrade dropping the cached transaction history. + * + * The feed is a re-fetchable cache ([FlipcashDatabase.Migration28To29]), so the contract is simply + * "after the upgrade, `messages` is empty and every other table is untouched". + */ +@RunWith(RobolectricTestRunner::class) +class TransactionHistoryMigrationTest { + + private val context = ApplicationProvider.getApplicationContext() + + private fun openV28Database(): 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 + db.execSQL( + "CREATE TABLE messages (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', " + + "text_substitutions TEXT, PRIMARY KEY(idBase58))" + ) + // A neighbouring table, to pin down that the migration only clears the feed. + db.execSQL( + "CREATE TABLE blocked_users (user_id_hex TEXT NOT NULL, " + + "blocked_at_epoch_ms INTEGER NOT NULL, PRIMARY KEY(user_id_hex))" + ) + return db + } + + private fun SupportSQLiteDatabase.count(table: String): Int = + query("SELECT COUNT(*) FROM $table").use { it.moveToFirst(); it.getInt(0) } + + private fun SupportSQLiteDatabase.insertMessage(id: String, timestamp: Long) = execSQL( + "INSERT INTO messages (idBase58, text, state, timestamp) VALUES (?,?,?,?)", + arrayOf(id, "sent \$1.00", "COMPLETED", timestamp), + ) + + @Test + fun `migration 28 to 29 drops the cached transaction history`() { + val db = openV28Database() + db.insertMessage("msgA", 1L) + db.insertMessage("msgB", 2L) + db.execSQL("INSERT INTO blocked_users VALUES (?,?)", arrayOf("u1", 111L)) + assertEquals(2, db.count("messages")) + + FlipcashDatabase.Migration28To29().onPostMigrate(SupportSQLiteConnection(db)) + + assertEquals(0, db.count("messages")) + // Only the feed is cleared — unrelated caches survive the upgrade. + assertEquals(1, db.count("blocked_users")) + + db.close() + } + + @Test + fun `migration 28 to 29 is a no-op on an already-empty feed`() { + val db = openV28Database() + + FlipcashDatabase.Migration28To29().onPostMigrate(SupportSQLiteConnection(db)) + + assertEquals(0, db.count("messages")) + + db.close() + } +}