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 d5b108cd5..697db2c7c 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 6668c8008..87950c695 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 000000000..2d5448221 --- /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 d2237c009..bbbcc16bd 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 @@ -87,8 +89,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 +190,28 @@ 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. + * + * 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(connection: SQLiteConnection) { + connection.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 d4b6d8eca..ade4b0fe4 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/TransactionHistoryMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/TransactionHistoryMigrationTest.kt new file mode 100644 index 000000000..de0b3ea23 --- /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() + } +} 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 cbf5604df..b2d058041 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 045b6e3ae..07ca82775 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 000000000..a71238d8d --- /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 228e97b93..fb949733d 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 78c8e7cee..380a861c3 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 000000000..bfbfccaec --- /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 8d2c2b3f8..0a960de3f 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 1a162f685..bdfa882ed 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 adc370f5d..e237f5f3f 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 000000000..90321ff91 --- /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 0f72190ba..e1a8e0c96 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 336e25f47..e609ab043 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 d79bbb376..f70019ce3 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) + } }