From 87941e0a421b4509cd807cec8c424c9b9292eb4a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:33:02 +0300 Subject: [PATCH 01/11] Add unified search_priority store and v80 migration Introduce a single search_priority table keyed by (query, type, item_id) to replace assets_priority, with Migration 79->80 and the exported schema. --- .../80.json | 2494 +++++++++++++++++ .../service/store/Migration_79_80Test.kt | 88 + .../store/database/AssetsPriorityDao.kt | 30 - .../service/store/database/GemDatabase.kt | 8 +- .../store/database/SearchPriorityDao.kt | 28 + .../store/database/di/DatabaseModule.kt | 5 +- .../store/database/di/Migration_79_80.kt | 18 + .../database/entities/DbAssetPriority.kt | 24 - .../database/entities/DbSearchPriority.kt | 39 + 9 files changed, 2674 insertions(+), 60 deletions(-) create mode 100644 android/data/services/store/schemas/com.gemwallet.android.data.service.store.database.GemDatabase/80.json create mode 100644 android/data/services/store/src/androidTest/kotlin/com/gemwallet/android/service/store/Migration_79_80Test.kt delete mode 100644 android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/AssetsPriorityDao.kt create mode 100644 android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/SearchPriorityDao.kt create mode 100644 android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/di/Migration_79_80.kt delete mode 100644 android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbAssetPriority.kt create mode 100644 android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbSearchPriority.kt diff --git a/android/data/services/store/schemas/com.gemwallet.android.data.service.store.database.GemDatabase/80.json b/android/data/services/store/schemas/com.gemwallet.android.data.service.store.database.GemDatabase/80.json new file mode 100644 index 0000000000..74d57638b2 --- /dev/null +++ b/android/data/services/store/schemas/com.gemwallet.android.data.service.store.database.GemDatabase/80.json @@ -0,0 +1,2494 @@ +{ + "formatVersion": 1, + "database": { + "version": 80, + "identityHash": "115e24b41923a5b39db8ef8b34dd3953", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `domain_name` TEXT, `type` TEXT NOT NULL, `position` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `index` INTEGER NOT NULL, `source` TEXT NOT NULL DEFAULT 'Import', `imageUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "domainName", + "columnName": "domain_name", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Import'" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wallet_id` TEXT NOT NULL, `derivation_path` TEXT NOT NULL, `address` TEXT NOT NULL, `chain` TEXT NOT NULL, `extendedPublicKey` TEXT, PRIMARY KEY(`wallet_id`, `chain`), FOREIGN KEY(`chain`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`wallet_id`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivation_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chain", + "columnName": "chain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extendedPublicKey", + "columnName": "extendedPublicKey", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "wallet_id", + "chain" + ] + }, + "indices": [ + { + "name": "index_accounts_chain", + "unique": false, + "columnNames": [ + "chain" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_chain` ON `${TABLE_NAME}` (`chain`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "chain" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "wallet_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chain` TEXT NOT NULL, `address` TEXT NOT NULL, `walletId` TEXT, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`chain`, `address`), FOREIGN KEY(`chain`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`walletId`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chain", + "columnName": "chain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chain", + "address" + ] + }, + "indices": [ + { + "name": "index_addresses_chain", + "unique": false, + "columnNames": [ + "chain" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_addresses_chain` ON `${TABLE_NAME}` (`chain`)" + }, + { + "name": "index_addresses_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_addresses_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "chain" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contacts_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `contactId` TEXT NOT NULL, `address` TEXT NOT NULL, `chain` TEXT NOT NULL, `memo` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`contactId`) REFERENCES `contacts`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chain", + "columnName": "chain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_contacts_addresses_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_contacts_addresses_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "contacts", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `type` TEXT NOT NULL, `chain` TEXT NOT NULL, `is_enabled` INTEGER NOT NULL, `is_buy_enabled` INTEGER NOT NULL, `is_sell_enabled` INTEGER NOT NULL, `is_swap_enabled` INTEGER NOT NULL, `is_stake_enabled` INTEGER NOT NULL, `staking_apr` REAL, `rank` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chain", + "columnName": "chain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBuyEnabled", + "columnName": "is_buy_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSellEnabled", + "columnName": "is_sell_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSwapEnabled", + "columnName": "is_swap_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStakeEnabled", + "columnName": "is_stake_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stakingApr", + "columnName": "staking_apr", + "affinity": "REAL" + }, + { + "fieldPath": "rank", + "columnName": "rank", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`asset_id` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `available` TEXT NOT NULL, `available_amount` REAL NOT NULL, `frozen` TEXT NOT NULL, `frozen_amount` REAL NOT NULL, `locked` TEXT NOT NULL, `locked_amount` REAL NOT NULL, `staked` TEXT NOT NULL, `staked_amount` REAL NOT NULL, `pending` TEXT NOT NULL, `pending_amount` REAL NOT NULL, `rewards` TEXT NOT NULL, `rewards_amount` REAL NOT NULL, `reserved` TEXT NOT NULL, `reserved_amount` REAL NOT NULL, `withdrawable` TEXT NOT NULL, `withdrawableAmount` REAL NOT NULL, `total_amount` REAL NOT NULL, `is_active` INTEGER NOT NULL, `is_pinned` INTEGER NOT NULL, `is_visible` INTEGER NOT NULL, `list_position` INTEGER NOT NULL, `votes` INTEGER NOT NULL DEFAULT 0, `energy_available` INTEGER NOT NULL DEFAULT 0, `energy_total` INTEGER NOT NULL DEFAULT 0, `bandwidth_available` INTEGER NOT NULL DEFAULT 0, `bandwidth_total` INTEGER NOT NULL DEFAULT 0, `updated_at` INTEGER, PRIMARY KEY(`asset_id`, `wallet_id`), FOREIGN KEY(`asset_id`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`wallet_id`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assetId", + "columnName": "asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "available", + "columnName": "available", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "availableAmount", + "columnName": "available_amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frozenAmount", + "columnName": "frozen_amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lockedAmount", + "columnName": "locked_amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "staked", + "columnName": "staked", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stakedAmount", + "columnName": "staked_amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pending", + "columnName": "pending", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingAmount", + "columnName": "pending_amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rewards", + "columnName": "rewards", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rewardsAmount", + "columnName": "rewards_amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "reserved", + "columnName": "reserved", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reservedAmount", + "columnName": "reserved_amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "withdrawable", + "columnName": "withdrawable", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "withdrawableAmount", + "columnName": "withdrawableAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "totalAmount", + "columnName": "total_amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "is_pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listPosition", + "columnName": "list_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "votes", + "columnName": "votes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "energyAvailable", + "columnName": "energy_available", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "energyTotal", + "columnName": "energy_total", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "bandwidthAvailable", + "columnName": "bandwidth_available", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "bandwidthTotal", + "columnName": "bandwidth_total", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "asset_id", + "wallet_id" + ] + }, + "indices": [ + { + "name": "index_balances_wallet_id", + "unique": false, + "columnNames": [ + "wallet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_balances_wallet_id` ON `${TABLE_NAME}` (`wallet_id`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "asset_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "wallet_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "prices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`asset_id` TEXT NOT NULL, `value` REAL, `usd_value` REAL, `day_changed` REAL, `currency` TEXT NOT NULL, PRIMARY KEY(`asset_id`))", + "fields": [ + { + "fieldPath": "assetId", + "columnName": "asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL" + }, + { + "fieldPath": "usdValue", + "columnName": "usd_value", + "affinity": "REAL" + }, + { + "fieldPath": "dayChanged", + "columnName": "day_changed", + "affinity": "REAL" + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "asset_id" + ] + } + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `walletId` TEXT NOT NULL, `hash` TEXT NOT NULL, `assetId` TEXT NOT NULL, `feeAssetId` TEXT NOT NULL, `owner` TEXT NOT NULL, `recipient` TEXT NOT NULL, `contract` TEXT, `metadata` TEXT, `state` TEXT NOT NULL, `type` TEXT NOT NULL, `blockNumber` TEXT NOT NULL, `sequence` TEXT NOT NULL, `fee` TEXT NOT NULL, `value` TEXT NOT NULL, `payload` TEXT, `direction` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`, `walletId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feeAssetId", + "columnName": "feeAssetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contract", + "columnName": "contract", + "affinity": "TEXT" + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockNumber", + "columnName": "blockNumber", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT" + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "walletId" + ] + } + }, + { + "tableName": "tx_swap_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `from_asset_id` TEXT NOT NULL, `to_asset_id` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `to_amount` TEXT NOT NULL, PRIMARY KEY(`tx_id`))", + "fields": [ + { + "fieldPath": "txId", + "columnName": "tx_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromAssetId", + "columnName": "from_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toAssetId", + "columnName": "to_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromAmount", + "columnName": "from_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toAmount", + "columnName": "to_amount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tx_id" + ] + } + }, + { + "tableName": "wallets_connections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `state` TEXT NOT NULL, `chains` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `expire_at` INTEGER NOT NULL, `app_name` TEXT NOT NULL, `app_description` TEXT NOT NULL, `app_url` TEXT NOT NULL, `app_icon` TEXT NOT NULL, `redirect_native` TEXT, `redirect_universal` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`wallet_id`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chains", + "columnName": "chains", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expireAt", + "columnName": "expire_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appName", + "columnName": "app_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appDescription", + "columnName": "app_description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appUrl", + "columnName": "app_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appIcon", + "columnName": "app_icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "redirectNative", + "columnName": "redirect_native", + "affinity": "TEXT" + }, + { + "fieldPath": "redirectUniversal", + "columnName": "redirect_universal", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_wallets_connections_wallet_id", + "unique": false, + "columnNames": [ + "wallet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_connections_wallet_id` ON `${TABLE_NAME}` (`wallet_id`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "wallet_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "stake_validators", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `assetId` TEXT NOT NULL, `validatorId` TEXT NOT NULL, `name` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `commission` REAL NOT NULL, `apr` REAL NOT NULL, `providerType` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assetId`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "validatorId", + "columnName": "validatorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commission", + "columnName": "commission", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "apr", + "columnName": "apr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "providerType", + "columnName": "providerType", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_stake_validators_assetId", + "unique": false, + "columnNames": [ + "assetId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stake_validators_assetId` ON `${TABLE_NAME}` (`assetId`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "assetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "stake_delegations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `walletId` TEXT NOT NULL, `assetId` TEXT NOT NULL, `validatorId` TEXT NOT NULL, `state` TEXT NOT NULL, `delegationId` TEXT NOT NULL, `balance` TEXT NOT NULL, `shares` TEXT NOT NULL, `rewards` TEXT NOT NULL, `completionDate` INTEGER, PRIMARY KEY(`walletId`, `id`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`assetId`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`validatorId`) REFERENCES `stake_validators`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "validatorId", + "columnName": "validatorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "delegationId", + "columnName": "delegationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shares", + "columnName": "shares", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rewards", + "columnName": "rewards", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completionDate", + "columnName": "completionDate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "id" + ] + }, + "indices": [ + { + "name": "index_stake_delegations_assetId", + "unique": false, + "columnNames": [ + "assetId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stake_delegations_assetId` ON `${TABLE_NAME}` (`assetId`)" + }, + { + "name": "index_stake_delegations_validatorId", + "unique": false, + "columnNames": [ + "validatorId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stake_delegations_validatorId` ON `${TABLE_NAME}` (`validatorId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "assetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "stake_validators", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "validatorId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `status` TEXT NOT NULL, `priority` INTEGER NOT NULL, `chain` TEXT NOT NULL, PRIMARY KEY(`url`), FOREIGN KEY(`chain`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chain", + "columnName": "chain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + }, + "indices": [ + { + "name": "index_nodes_chain", + "unique": false, + "columnNames": [ + "chain" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_chain` ON `${TABLE_NAME}` (`chain`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "chain" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `wallet_id` TEXT NOT NULL, `currency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "banners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wallet_id` TEXT NOT NULL, `asset_id` TEXT NOT NULL, `chain` TEXT, `state` TEXT NOT NULL, `event` TEXT NOT NULL, PRIMARY KEY(`wallet_id`, `asset_id`), FOREIGN KEY(`chain`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chain", + "columnName": "chain", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "wallet_id", + "asset_id" + ] + }, + "indices": [ + { + "name": "index_banners_event", + "unique": false, + "columnNames": [ + "event" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_banners_event` ON `${TABLE_NAME}` (`event`)" + }, + { + "name": "index_banners_wallet_id", + "unique": false, + "columnNames": [ + "wallet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_banners_wallet_id` ON `${TABLE_NAME}` (`wallet_id`)" + }, + { + "name": "index_banners_chain", + "unique": false, + "columnNames": [ + "chain" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_banners_chain` ON `${TABLE_NAME}` (`chain`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "chain" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "price_alerts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `assetId` TEXT NOT NULL, `currency` TEXT NOT NULL, `price` REAL, `pricePercentChange` REAL, `priceDirection` TEXT, `lastNotifiedAt` INTEGER, `enabled` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL" + }, + { + "fieldPath": "pricePercentChange", + "columnName": "pricePercentChange", + "affinity": "REAL" + }, + { + "fieldPath": "priceDirection", + "columnName": "priceDirection", + "affinity": "TEXT" + }, + { + "fieldPath": "lastNotifiedAt", + "columnName": "lastNotifiedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "nft_collections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `chain` TEXT NOT NULL, `contractAddress` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `previewImageUrl` TEXT NOT NULL, `originalSourceUrl` TEXT NOT NULL, `status` TEXT, `links` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`chain`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "chain", + "columnName": "chain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractAddress", + "columnName": "contractAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "previewImageUrl", + "columnName": "previewImageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalSourceUrl", + "columnName": "originalSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "links", + "columnName": "links", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_nft_collections_chain", + "unique": false, + "columnNames": [ + "chain" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nft_collections_chain` ON `${TABLE_NAME}` (`chain`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "chain" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "nft_assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `collection_id` TEXT NOT NULL, `token_id` TEXT NOT NULL, `token_type` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `chain` TEXT NOT NULL, `contract_address` TEXT, `image_url` TEXT NOT NULL, `preview_image_url` TEXT NOT NULL, `original_image_url` TEXT NOT NULL, `attributes` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`collection_id`) REFERENCES `nft_collections`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`chain`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collection_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "token_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tokenType", + "columnName": "token_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "chain", + "columnName": "chain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractAddress", + "columnName": "contract_address", + "affinity": "TEXT" + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "previewImageUrl", + "columnName": "preview_image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalSourceUrl", + "columnName": "original_image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_nft_assets_collection_id", + "unique": false, + "columnNames": [ + "collection_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nft_assets_collection_id` ON `${TABLE_NAME}` (`collection_id`)" + }, + { + "name": "index_nft_assets_chain", + "unique": false, + "columnNames": [ + "chain" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nft_assets_chain` ON `${TABLE_NAME}` (`chain`)" + } + ], + "foreignKeys": [ + { + "table": "nft_collections", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "collection_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "chain" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "nft_assets_associations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wallet_id` TEXT NOT NULL, `asset_id` TEXT NOT NULL, PRIMARY KEY(`wallet_id`, `asset_id`), FOREIGN KEY(`asset_id`) REFERENCES `nft_assets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`wallet_id`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "asset_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "wallet_id", + "asset_id" + ] + }, + "indices": [ + { + "name": "index_nft_assets_associations_asset_id", + "unique": false, + "columnNames": [ + "asset_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nft_assets_associations_asset_id` ON `${TABLE_NAME}` (`asset_id`)" + } + ], + "foreignKeys": [ + { + "table": "nft_assets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "asset_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "wallet_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`asset_id` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`asset_id`, `name`), FOREIGN KEY(`asset_id`) REFERENCES `asset`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assetId", + "columnName": "asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "asset_id", + "name" + ] + }, + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "asset_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_market", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`asset_id` TEXT NOT NULL, `marketCap` REAL, `marketCapFdv` REAL, `marketCapRank` INTEGER, `totalVolume` REAL, `circulatingSupply` REAL, `totalSupply` REAL, `maxSupply` REAL, `allTimeHigh` REAL, `allTimeHighDate` INTEGER, `allTimeHighChangePercentage` REAL, `allTimeLow` REAL, `allTimeLowDate` INTEGER, `allTimeLowChangePercentage` REAL, PRIMARY KEY(`asset_id`), FOREIGN KEY(`asset_id`) REFERENCES `asset`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assetId", + "columnName": "asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketCap", + "columnName": "marketCap", + "affinity": "REAL" + }, + { + "fieldPath": "marketCapFdv", + "columnName": "marketCapFdv", + "affinity": "REAL" + }, + { + "fieldPath": "marketCapRank", + "columnName": "marketCapRank", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalVolume", + "columnName": "totalVolume", + "affinity": "REAL" + }, + { + "fieldPath": "circulatingSupply", + "columnName": "circulatingSupply", + "affinity": "REAL" + }, + { + "fieldPath": "totalSupply", + "columnName": "totalSupply", + "affinity": "REAL" + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "REAL" + }, + { + "fieldPath": "allTimeHigh", + "columnName": "allTimeHigh", + "affinity": "REAL" + }, + { + "fieldPath": "allTimeHighDate", + "columnName": "allTimeHighDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "allTimeHighChangePercentage", + "columnName": "allTimeHighChangePercentage", + "affinity": "REAL" + }, + { + "fieldPath": "allTimeLow", + "columnName": "allTimeLow", + "affinity": "REAL" + }, + { + "fieldPath": "allTimeLowDate", + "columnName": "allTimeLowDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "allTimeLowChangePercentage", + "columnName": "allTimeLowChangePercentage", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "asset_id" + ] + }, + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "asset_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_priority", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `type` TEXT NOT NULL, `item_id` TEXT NOT NULL, `priority` INTEGER NOT NULL, PRIMARY KEY(`query`, `type`, `item_id`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query", + "type", + "item_id" + ] + } + }, + { + "tableName": "currency_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`currency`))", + "fields": [ + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "currency" + ] + } + }, + { + "tableName": "fiat_transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `walletId` TEXT NOT NULL, `assetId` TEXT NOT NULL, `transactionType` TEXT NOT NULL, `provider` TEXT NOT NULL, `status` TEXT NOT NULL, `fiatAmount` REAL NOT NULL, `fiatCurrency` TEXT NOT NULL, `value` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `detailsUrl` TEXT, PRIMARY KEY(`id`, `walletId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`assetId`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fiatAmount", + "columnName": "fiatAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "fiatCurrency", + "columnName": "fiatCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "detailsUrl", + "columnName": "detailsUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "walletId" + ] + }, + "indices": [ + { + "name": "index_fiat_transactions_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_fiat_transactions_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_fiat_transactions_assetId", + "unique": false, + "columnNames": [ + "assetId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_fiat_transactions_assetId` ON `${TABLE_NAME}` (`assetId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "assetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "recent_assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`asset_id` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `to_asset_id` TEXT, `type` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`asset_id`, `wallet_id`, `type`), FOREIGN KEY(`asset_id`) REFERENCES `asset`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`wallet_id`) REFERENCES `wallets`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assetId", + "columnName": "asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toAssetId", + "columnName": "to_asset_id", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "asset_id", + "wallet_id", + "type" + ] + }, + "indices": [ + { + "name": "index_recent_assets_wallet_id", + "unique": false, + "columnNames": [ + "wallet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_assets_wallet_id` ON `${TABLE_NAME}` (`wallet_id`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "asset_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "wallet_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "perpetuals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `provider` TEXT NOT NULL, `assetId` TEXT NOT NULL, `identifier` TEXT NOT NULL, `price` REAL NOT NULL, `pricePercentChange24h` REAL NOT NULL, `openInterest` REAL NOT NULL, `volume24h` REAL NOT NULL, `funding` REAL NOT NULL, `maxLeverage` INTEGER NOT NULL, `isIsolatedOnly` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assetId`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identifier", + "columnName": "identifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pricePercentChange24h", + "columnName": "pricePercentChange24h", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "openInterest", + "columnName": "openInterest", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "volume24h", + "columnName": "volume24h", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "funding", + "columnName": "funding", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "maxLeverage", + "columnName": "maxLeverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIsolatedOnly", + "columnName": "isIsolatedOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "perpetuals_asset_id_idx", + "unique": false, + "columnNames": [ + "assetId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `perpetuals_asset_id_idx` ON `${TABLE_NAME}` (`assetId`)" + } + ], + "foreignKeys": [ + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "assetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "perpetuals_positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `walletId` TEXT NOT NULL, `perpetualId` TEXT NOT NULL, `assetId` TEXT NOT NULL, `size` REAL NOT NULL, `sizeValue` REAL NOT NULL, `leverage` INTEGER NOT NULL, `entryPrice` REAL, `liquidationPrice` REAL, `marginType` TEXT NOT NULL, `direction` TEXT NOT NULL, `marginAmount` REAL NOT NULL, `takeProfitPrice` REAL, `takeProfitType` TEXT, `takeProfitOrderId` TEXT, `stopLossPrice` REAL, `stopLossType` TEXT, `stopLossOrderId` TEXT, `pnl` REAL NOT NULL, `funding` REAL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`, `walletId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`perpetualId`) REFERENCES `perpetuals`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`assetId`) REFERENCES `asset`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "perpetualId", + "columnName": "perpetualId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sizeValue", + "columnName": "sizeValue", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryPrice", + "columnName": "entryPrice", + "affinity": "REAL" + }, + { + "fieldPath": "liquidationPrice", + "columnName": "liquidationPrice", + "affinity": "REAL" + }, + { + "fieldPath": "marginType", + "columnName": "marginType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marginAmount", + "columnName": "marginAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "takeProfitPrice", + "columnName": "takeProfitPrice", + "affinity": "REAL" + }, + { + "fieldPath": "takeProfitType", + "columnName": "takeProfitType", + "affinity": "TEXT" + }, + { + "fieldPath": "takeProfitOrderId", + "columnName": "takeProfitOrderId", + "affinity": "TEXT" + }, + { + "fieldPath": "stopLossPrice", + "columnName": "stopLossPrice", + "affinity": "REAL" + }, + { + "fieldPath": "stopLossType", + "columnName": "stopLossType", + "affinity": "TEXT" + }, + { + "fieldPath": "stopLossOrderId", + "columnName": "stopLossOrderId", + "affinity": "TEXT" + }, + { + "fieldPath": "pnl", + "columnName": "pnl", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "funding", + "columnName": "funding", + "affinity": "REAL" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "walletId" + ] + }, + "indices": [ + { + "name": "perpetuals_positions_wallet_id_idx", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `perpetuals_positions_wallet_id_idx` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "perpetuals_positions_perpetual_id_idx", + "unique": false, + "columnNames": [ + "perpetualId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `perpetuals_positions_perpetual_id_idx` ON `${TABLE_NAME}` (`perpetualId`)" + }, + { + "name": "perpetuals_positions_asset_id_idx", + "unique": false, + "columnNames": [ + "assetId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `perpetuals_positions_asset_id_idx` ON `${TABLE_NAME}` (`assetId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "perpetuals", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "perpetualId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "asset", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "assetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "in_app_notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `read_at` INTEGER, `created_at` INTEGER NOT NULL, `item` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`wallet_id`) REFERENCES `wallets`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readAt", + "columnName": "read_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "item", + "columnName": "item", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_in_app_notifications_wallet_id", + "unique": false, + "columnNames": [ + "wallet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_in_app_notifications_wallet_id` ON `${TABLE_NAME}` (`wallet_id`)" + }, + { + "name": "index_in_app_notifications_created_at", + "unique": false, + "columnNames": [ + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_in_app_notifications_created_at` ON `${TABLE_NAME}` (`created_at`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "wallet_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, '115e24b41923a5b39db8ef8b34dd3953')" + ] + } +} \ No newline at end of file diff --git a/android/data/services/store/src/androidTest/kotlin/com/gemwallet/android/service/store/Migration_79_80Test.kt b/android/data/services/store/src/androidTest/kotlin/com/gemwallet/android/service/store/Migration_79_80Test.kt new file mode 100644 index 0000000000..aa5a6e3a7f --- /dev/null +++ b/android/data/services/store/src/androidTest/kotlin/com/gemwallet/android/service/store/Migration_79_80Test.kt @@ -0,0 +1,88 @@ +package com.gemwallet.android.service.store + +import android.content.Context +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.gemwallet.android.data.service.store.database.GemDatabase +import com.gemwallet.android.data.service.store.database.di.Migration_79_80 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class Migration_79_80Test { + + private val testDb = "migration-79-80-test" + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + GemDatabase::class.java, + emptyList(), + FrameworkSQLiteOpenHelperFactory() + ) + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + context.deleteDatabase(testDb) + } + + @Test + fun migrate79To80_dropsAssetsPriorityAndCreatesUnifiedSearchPriority() = runBlocking { + helper.createDatabase(testDb, 79).apply { + execSQL("INSERT INTO assets_priority (`query`, asset_id, priority) VALUES ('btc', 'bitcoin', 0)") + close() + } + + val migratedDb = helper.runMigrationsAndValidate(testDb, 80, true, Migration_79_80) + + assertFalse(migratedDb.hasTable("assets_priority")) + assertTrue(migratedDb.hasTable("search_priority")) + assertTrue(migratedDb.hasColumn("search_priority", "query")) + assertTrue(migratedDb.hasColumn("search_priority", "type")) + assertTrue(migratedDb.hasColumn("search_priority", "item_id")) + assertTrue(migratedDb.hasColumn("search_priority", "priority")) + + migratedDb.execSQL("INSERT INTO search_priority (`query`, type, item_id, priority) VALUES ('btc', 'asset', 'bitcoin', 0)") + migratedDb.execSQL("INSERT INTO search_priority (`query`, type, item_id, priority) VALUES ('btc', 'perpetual', 'hypercore_perpetual::BTC', 0)") + assertEquals(2, migratedDb.longForQuery("SELECT COUNT(*) FROM search_priority")) + migratedDb.close() + } + + private fun SupportSQLiteDatabase.longForQuery(query: String): Long { + val cursor = query(query) + return cursor.use { + assertTrue(it.moveToFirst()) + it.getLong(0) + } + } + + private fun SupportSQLiteDatabase.hasTable(name: String): Boolean { + val cursor = query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '$name'") + return cursor.use { it.moveToFirst() } + } + + private fun SupportSQLiteDatabase.hasColumn(table: String, column: String): Boolean { + val cursor = query("PRAGMA table_info($table)") + return cursor.use { + while (it.moveToNext()) { + if (it.getString(1) == column) { + return@use true + } + } + false + } + } +} diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/AssetsPriorityDao.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/AssetsPriorityDao.kt deleted file mode 100644 index 9a88304a15..0000000000 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/AssetsPriorityDao.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.gemwallet.android.data.service.store.database - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy.Companion.REPLACE -import androidx.room.Query -import androidx.room.Transaction -import com.gemwallet.android.data.service.store.database.entities.DbAssetPriority -import kotlinx.coroutines.flow.Flow - -@Dao -interface AssetsPriorityDao { - - @Insert(onConflict = REPLACE) - suspend fun insert(priorities: List) - - @Query("DELETE FROM assets_priority WHERE `query` = :query") - suspend fun deleteByQuery(query: String) - - @Transaction - suspend fun put(priorities: List) { - priorities.firstOrNull()?.query?.let { deleteByQuery(it) } - insert(priorities) - } - - @Query(""" - SELECT COUNT(asset_id) FROM assets_priority WHERE `query` = :query - """) - fun hasPriorities(query: String): Flow -} diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/GemDatabase.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/GemDatabase.kt index b740b2a2a4..71cc9ca880 100644 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/GemDatabase.kt +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/GemDatabase.kt @@ -8,7 +8,6 @@ import com.gemwallet.android.data.service.store.database.entities.DbAddress import com.gemwallet.android.data.service.store.database.entities.DbAsset import com.gemwallet.android.data.service.store.database.entities.DbAssetLink import com.gemwallet.android.data.service.store.database.entities.DbAssetMarket -import com.gemwallet.android.data.service.store.database.entities.DbAssetPriority import com.gemwallet.android.data.service.store.database.entities.DbBalance import com.gemwallet.android.data.service.store.database.entities.DbBanner import com.gemwallet.android.data.service.store.database.entities.DbConnection @@ -28,13 +27,14 @@ import com.gemwallet.android.data.service.store.database.entities.DbPerpetualPos import com.gemwallet.android.data.service.store.database.entities.DbPrice import com.gemwallet.android.data.service.store.database.entities.DbPriceAlert import com.gemwallet.android.data.service.store.database.entities.DbRecentActivity +import com.gemwallet.android.data.service.store.database.entities.DbSearchPriority import com.gemwallet.android.data.service.store.database.entities.DbSession import com.gemwallet.android.data.service.store.database.entities.DbTransaction import com.gemwallet.android.data.service.store.database.entities.DbTxSwapMetadata import com.gemwallet.android.data.service.store.database.entities.DbWallet @Database( - version = 79, + version = 80, entities = [ DbWallet::class, DbAccount::class, @@ -58,7 +58,7 @@ import com.gemwallet.android.data.service.store.database.entities.DbWallet DbNFTAssociation::class, DbAssetLink::class, DbAssetMarket::class, - DbAssetPriority::class, + DbSearchPriority::class, DbFiatRate::class, DbFiatTransaction::class, DbRecentActivity::class, @@ -99,7 +99,7 @@ abstract class GemDatabase : RoomDatabase() { abstract fun nftDao(): NftDao - abstract fun assetsPriorityDao(): AssetsPriorityDao + abstract fun searchPriorityDao(): SearchPriorityDao abstract fun perpetualDao(): PerpetualDao diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/SearchPriorityDao.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/SearchPriorityDao.kt new file mode 100644 index 0000000000..d6a5cc1007 --- /dev/null +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/SearchPriorityDao.kt @@ -0,0 +1,28 @@ +package com.gemwallet.android.data.service.store.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import androidx.room.Transaction +import com.gemwallet.android.data.service.store.database.entities.DbSearchPriority +import kotlinx.coroutines.flow.Flow + +@Dao +interface SearchPriorityDao { + + @Insert(onConflict = REPLACE) + suspend fun insert(priorities: List) + + @Query("DELETE FROM search_priority WHERE `query` = :query AND type = :type") + suspend fun deleteByQuery(query: String, type: String) + + @Transaction + suspend fun put(priorities: List) { + priorities.firstOrNull()?.let { deleteByQuery(it.query, it.type) } + insert(priorities) + } + + @Query("SELECT COUNT(item_id) FROM search_priority WHERE `query` = :query AND type = :type") + fun hasPriorities(query: String, type: String): Flow +} diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/di/DatabaseModule.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/di/DatabaseModule.kt index 470dcf192d..c51b5ad8cc 100644 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/di/DatabaseModule.kt +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/di/DatabaseModule.kt @@ -6,7 +6,6 @@ import com.gemwallet.android.application.PasswordStore import com.gemwallet.android.data.service.store.database.AccountsDao import com.gemwallet.android.data.service.store.database.AddressesDao import com.gemwallet.android.data.service.store.database.AssetsDao -import com.gemwallet.android.data.service.store.database.AssetsPriorityDao import com.gemwallet.android.data.service.store.database.BalancesDao import com.gemwallet.android.data.service.store.database.BannersDao import com.gemwallet.android.data.service.store.database.ConnectionsDao @@ -19,6 +18,7 @@ import com.gemwallet.android.data.service.store.database.NodesDao import com.gemwallet.android.data.service.store.database.PerpetualDao import com.gemwallet.android.data.service.store.database.PerpetualPositionDao import com.gemwallet.android.data.service.store.database.PriceAlertsDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import com.gemwallet.android.data.service.store.database.PricesDao import com.gemwallet.android.data.service.store.database.RoomStoreTransactionRunner import com.gemwallet.android.data.service.store.database.SessionDao @@ -81,6 +81,7 @@ object DatabaseModule { .addMigrations(Migration_76_77) .addMigrations(Migration_77_78) .addMigrations(Migration_78_79) + .addMigrations(Migration_79_80) .build() @Singleton @@ -149,7 +150,7 @@ object DatabaseModule { @Singleton @Provides - fun provideAssetsPriorityDao(db: GemDatabase): AssetsPriorityDao = db.assetsPriorityDao() + fun provideSearchPriorityDao(db: GemDatabase): SearchPriorityDao = db.searchPriorityDao() @Singleton @Provides diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/di/Migration_79_80.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/di/Migration_79_80.kt new file mode 100644 index 0000000000..15d9fde75c --- /dev/null +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/di/Migration_79_80.kt @@ -0,0 +1,18 @@ +package com.gemwallet.android.data.service.store.database.di + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +object Migration_79_80 : Migration(79, 80) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS `assets_priority`") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `search_priority` (" + + "`query` TEXT NOT NULL, " + + "`type` TEXT NOT NULL, " + + "`item_id` TEXT NOT NULL, " + + "`priority` INTEGER NOT NULL, " + + "PRIMARY KEY(`query`, `type`, `item_id`))" + ) + } +} diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbAssetPriority.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbAssetPriority.kt deleted file mode 100644 index eac751b47a..0000000000 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbAssetPriority.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.gemwallet.android.data.service.store.database.entities - -import androidx.room.ColumnInfo -import androidx.room.Entity -import com.gemwallet.android.ext.toIdentifier -import com.wallet.core.primitives.AssetBasic - -@Entity( - tableName = "assets_priority", - primaryKeys = ["query", "asset_id"], -) -data class DbAssetPriority( - val query: String, - @ColumnInfo(name = "asset_id") val assetId: String, - val priority: Int, -) - -fun List.toRecordPriority(query: String): List = mapIndexed { index, basic -> - DbAssetPriority( - query = query, - assetId = basic.asset.id.toIdentifier(), - priority = index, - ) -} diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbSearchPriority.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbSearchPriority.kt new file mode 100644 index 0000000000..d760ad8ded --- /dev/null +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbSearchPriority.kt @@ -0,0 +1,39 @@ +package com.gemwallet.android.data.service.store.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.gemwallet.android.ext.toIdentifier +import com.wallet.core.primitives.AssetBasic +import com.wallet.core.primitives.PerpetualSearchData +import com.wallet.core.primitives.SearchItemType + +@Entity( + tableName = "search_priority", + primaryKeys = ["query", "type", "item_id"], +) +data class DbSearchPriority( + val query: String, + val type: String, + @ColumnInfo(name = "item_id") val itemId: String, + val priority: Int, +) + +@JvmName("assetsToSearchPriority") +fun List.toSearchPriority(query: String): List = mapIndexed { index, basic -> + DbSearchPriority( + query = query, + type = SearchItemType.Asset.string, + itemId = basic.asset.id.toIdentifier(), + priority = index, + ) +} + +@JvmName("perpetualsToSearchPriority") +fun List.toSearchPriority(query: String): List = mapIndexed { index, data -> + DbSearchPriority( + query = query, + type = SearchItemType.Perpetual.string, + itemId = data.perpetual.id.toIdentifier(), + priority = index, + ) +} From 02da3489c24f6d36909c21d728efa1d3aca1491b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:33:02 +0300 Subject: [PATCH 02/11] Join search_priority and match contracts in asset/perpetual DAOs Rank results via search_priority joins and match assets by contract/tokenId in local asset search. --- .../data/service/store/database/AssetsDao.kt | 25 +++++++++++-------- .../service/store/database/PerpetualDao.kt | 9 +++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/AssetsDao.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/AssetsDao.kt index f7bbd47c1f..1b1409d275 100644 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/AssetsDao.kt +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/AssetsDao.kt @@ -260,7 +260,8 @@ interface AssetsDao { AND (walletId = :walletId OR walletId IS NULL) AND assetRank > 0 AND (symbol LIKE '%' || :query || '%' - OR name LIKE '%' || :query || '%' COLLATE NOCASE) + OR name LIKE '%' || :query || '%' COLLATE NOCASE + OR SUBSTR(id, INSTR(id, '_') + 1) LIKE '%' || :query || '%' COLLATE NOCASE) ORDER BY balanceFiatTotalAmount DESC, assetRank DESC """) fun search(walletId: String, query: String, exclude: List = emptyList()): Flow> @@ -268,14 +269,14 @@ interface AssetsDao { @Query(""" SELECT asset_info.* FROM $ASSET_INFO - JOIN assets_priority ON asset_info.id = assets_priority.asset_id + JOIN search_priority ON asset_info.id = search_priority.item_id AND search_priority.type = 'asset' WHERE asset_info.id NOT IN (:exclude) AND chain IN (SELECT chain FROM accounts WHERE wallet_id = :walletId) AND (walletId = :walletId OR walletId IS NULL) AND assetRank > 0 - AND assets_priority.`query` = :query - ORDER BY balanceFiatTotalAmount DESC, assets_priority.priority ASC, assetRank DESC + AND search_priority.`query` = :query + ORDER BY balanceFiatTotalAmount DESC, search_priority.priority ASC, assetRank DESC """) fun searchWithPriority(walletId: String, query: String, exclude: List = emptyList()): Flow> @@ -284,7 +285,9 @@ interface AssetsDao { FROM $ASSET_INFO WHERE assetRank > 0 AND - (symbol LIKE '%' || :query || '%' OR name LIKE '%' || :query || '%' COLLATE NOCASE) + (symbol LIKE '%' || :query || '%' + OR name LIKE '%' || :query || '%' COLLATE NOCASE + OR SUBSTR(id, INSTR(id, '_') + 1) LIKE '%' || :query || '%' COLLATE NOCASE) ORDER BY balanceFiatTotalAmount DESC, assetRank DESC """) fun searchByAllWallets(walletId: String, query: String): Flow> @@ -292,12 +295,12 @@ interface AssetsDao { @Query(""" SELECT asset_info.* FROM $ASSET_INFO - JOIN assets_priority ON asset_info.id = assets_priority.asset_id + JOIN search_priority ON asset_info.id = search_priority.item_id AND search_priority.type = 'asset' WHERE assetRank > 0 AND - assets_priority.`query` = :query - ORDER BY balanceFiatTotalAmount DESC, assets_priority.priority ASC, assetRank DESC + search_priority.`query` = :query + ORDER BY balanceFiatTotalAmount DESC, search_priority.priority ASC, assetRank DESC """) fun searchByAllWalletsWithPriority(walletId: String, query: String): Flow> @@ -315,12 +318,12 @@ interface AssetsDao { @Query(""" SELECT asset_info.* FROM $ASSET_INFO - JOIN assets_priority ON asset_info.id = assets_priority.asset_id + JOIN search_priority ON asset_info.id = search_priority.item_id AND search_priority.type = 'asset' WHERE (chain IN (:byChains) OR id IN (:byAssets) ) AND assetRank > 0 - AND assets_priority.`query` = :query - ORDER BY assets_priority.priority ASC, assetRank DESC + AND search_priority.`query` = :query + ORDER BY search_priority.priority ASC, assetRank DESC """) fun swapSearchWithPriority(walletId: String, query: String, byChains: List, byAssets: List): Flow> diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/PerpetualDao.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/PerpetualDao.kt index f9c6b28413..aaa0141786 100644 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/PerpetualDao.kt +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/PerpetualDao.kt @@ -34,6 +34,15 @@ interface PerpetualDao { @Query("SELECT * FROM perpetuals WHERE volume24h > 0 ORDER BY volume24h DESC") fun getPerpetualsData(): Flow> + @Transaction + @Query(""" + SELECT perpetuals.* FROM perpetuals + JOIN search_priority ON perpetuals.id = search_priority.item_id AND search_priority.type = 'perpetual' + WHERE search_priority.`query` = :query + ORDER BY search_priority.priority ASC, perpetuals.volume24h DESC + """) + fun searchWithPriority(query: String): Flow> + @Transaction @Query("SELECT * FROM perpetuals WHERE id = :perpetualId") fun getPerpetual(perpetualId: String): Flow From 715cb951301e0d392764c76b16d924c98704318b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:33:02 +0300 Subject: [PATCH 03/11] Add GemSearch coordinator over the /v1/search endpoint Expose the unified /v1/search endpoint returning assets and perpetuals behind a GemSearch coordinator alongside SearchAssets. --- .../coordinators/asset/SearchAssetsImpl.kt | 18 ++++++++++++++++-- .../data/coordinators/di/AssetModule.kt | 13 +++++++++++-- .../coordinators/asset/SearchAssetsImplTest.kt | 6 ++++-- .../data/services/gemapi/GemApiClient.kt | 10 +++++++++- .../assets/coordinators/GemSearch.kt | 13 +++++++++++++ .../assets/coordinators/SearchAssets.kt | 2 +- 6 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GemSearch.kt diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/SearchAssetsImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/SearchAssetsImpl.kt index 79746fd49b..1d7d404463 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/SearchAssetsImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/SearchAssetsImpl.kt @@ -1,21 +1,23 @@ package com.gemwallet.android.data.coordinators.asset +import com.gemwallet.android.application.assets.coordinators.GemSearch import com.gemwallet.android.application.assets.coordinators.SearchAssets import com.gemwallet.android.data.services.gemapi.GemApiClient import com.wallet.core.primitives.AssetBasic import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.AssetTag import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.SearchResponse class SearchAssetsImpl( private val gemApiClient: GemApiClient, -) : SearchAssets { +) : SearchAssets, GemSearch { override suspend fun search( query: String, chains: List, tags: List, - ): List { + ): SearchResponse { return gemApiClient.search( query = query, chains = chains.joinToString(",") { it.string }, @@ -23,6 +25,18 @@ class SearchAssetsImpl( ) } + override suspend fun searchAssets( + query: String, + chains: List, + tags: List, + ): List { + return gemApiClient.searchAssets( + query = query, + chains = chains.joinToString(",") { it.string }, + tags = tags.joinToString(",") { it.string }, + ) + } + override suspend fun getAssets(assetIds: List): List { return gemApiClient.getAssets(assetIds) } diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt index b475f171f7..327354c7f1 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt @@ -1,6 +1,7 @@ package com.gemwallet.android.data.coordinators.di import com.gemwallet.android.application.assets.coordinators.EnableAsset +import com.gemwallet.android.application.assets.coordinators.GemSearch import com.gemwallet.android.application.assets.coordinators.GetActiveAssetsInfo import com.gemwallet.android.application.assets.coordinators.GetAssetById import com.gemwallet.android.application.assets.coordinators.GetAssetChartData @@ -64,12 +65,20 @@ import javax.inject.Singleton object AssetModule { @Provides @Singleton - fun provideSearchAssets( + fun provideSearchAssetsImpl( gemApiClient: GemApiClient, - ): SearchAssets = SearchAssetsImpl( + ): SearchAssetsImpl = SearchAssetsImpl( gemApiClient = gemApiClient, ) + @Provides + @Singleton + fun provideSearchAssets(impl: SearchAssetsImpl): SearchAssets = impl + + @Provides + @Singleton + fun provideGemSearch(impl: SearchAssetsImpl): GemSearch = impl + @Provides @Singleton fun provideGetActiveAssetsInfo(assetsRepository: AssetsRepository): GetActiveAssetsInfo = diff --git a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/SearchAssetsImplTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/SearchAssetsImplTest.kt index ad7e26f557..1eceef9419 100644 --- a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/SearchAssetsImplTest.kt +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/SearchAssetsImplTest.kt @@ -4,6 +4,7 @@ import com.gemwallet.android.data.services.gemapi.GemApiClient import com.gemwallet.android.testkit.mockAssetBasic import com.wallet.core.primitives.AssetTag import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.SearchResponse import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -22,13 +23,14 @@ class SearchAssetsImplTest { @Test fun search_formatsChainsAndTagsForGemApi() = runTest { val asset = mockAssetBasic() + val response = SearchResponse(assets = listOf(asset), perpetuals = emptyList(), nfts = emptyList()) coEvery { gemApiClient.search( query = "usd", chains = "bitcoin,ethereum", tags = "trending,stablecoins", ) - } returns listOf(asset) + } returns response val result = subject.search( query = "usd", @@ -36,7 +38,7 @@ class SearchAssetsImplTest { tags = listOf(AssetTag.Trending, AssetTag.Stablecoins), ) - assertEquals(listOf(asset), result) + assertEquals(response, result) } @Test diff --git a/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemApiClient.kt b/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemApiClient.kt index dbbb5ae6b3..f76332c50f 100644 --- a/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemApiClient.kt +++ b/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemApiClient.kt @@ -5,6 +5,7 @@ import com.wallet.core.primitives.AssetFull import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.Charts import com.wallet.core.primitives.ConfigResponse +import com.wallet.core.primitives.SearchResponse import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -27,9 +28,16 @@ interface GemApiClient { ): List @GET("/v1/assets/search") - suspend fun search( + suspend fun searchAssets( @Query("query") query: String, @Query("chains") chains: String, @Query("tags") tags: String, ): List + + @GET("/v1/search") + suspend fun search( + @Query("query") query: String, + @Query("chains") chains: String, + @Query("tags") tags: String, + ): SearchResponse } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GemSearch.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GemSearch.kt new file mode 100644 index 0000000000..786a9dbf9b --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GemSearch.kt @@ -0,0 +1,13 @@ +package com.gemwallet.android.application.assets.coordinators + +import com.wallet.core.primitives.AssetTag +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.SearchResponse + +interface GemSearch { + suspend fun search( + query: String, + chains: List = emptyList(), + tags: List = emptyList(), + ): SearchResponse +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/SearchAssets.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/SearchAssets.kt index 5408c941d2..74aaed4e39 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/SearchAssets.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/SearchAssets.kt @@ -6,7 +6,7 @@ import com.wallet.core.primitives.AssetTag import com.wallet.core.primitives.Chain interface SearchAssets { - suspend fun search( + suspend fun searchAssets( query: String, chains: List = emptyList(), tags: List = emptyList(), From 19b7c9076406198ca4b55a103b2b73bfdd7c5ec7 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:33:02 +0300 Subject: [PATCH 04/11] Orchestrate wallet search ingestion via WalletSearchTokens Fetch via GemSearch and persist assets, perpetuals, and ranking; wire repositories, DI, and showPerpetuals gating. --- .../repositories/assets/AssetsRepository.kt | 9 +-- .../data/repositories/config/UserConfigExt.kt | 11 +++ .../data/repositories/di/AssetsModule.kt | 6 +- .../data/repositories/di/PerpetualModule.kt | 3 + .../data/repositories/di/TokensModule.kt | 20 +++++- .../perpetual/PerpetualRepositoryImpl.kt | 31 ++++++-- .../repositories/tokens/TokensRepository.kt | 21 +++--- .../data/repositories/tokens/WalletSearch.kt | 7 ++ .../repositories/tokens/WalletSearchTokens.kt | 45 ++++++++++++ .../assets/AssetsRepositoryTest.kt | 10 +-- .../tokens/TokensRepositoryTest.kt | 24 +++---- .../tokens/WalletSearchTokensTest.kt | 71 +++++++++++++++++++ 12 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/UserConfigExt.kt create mode 100644 android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearch.kt create mode 100644 android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokens.kt create mode 100644 android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokensTest.kt diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepository.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepository.kt index c5ccc62642..abfa12952d 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepository.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepository.kt @@ -11,9 +11,9 @@ import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.repositories.stream.StreamSubscriptionService import com.gemwallet.android.data.repositories.tokens.toPriorityQuery import com.gemwallet.android.data.service.store.database.AssetsDao -import com.gemwallet.android.data.service.store.database.AssetsPriorityDao import com.gemwallet.android.data.service.store.database.BalancesDao import com.gemwallet.android.data.service.store.database.PricesDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import com.gemwallet.android.data.service.store.database.entities.DbAsset import com.gemwallet.android.data.service.store.database.entities.DbAssetBasicUpdate import com.gemwallet.android.data.service.store.database.entities.DbRecentActivity @@ -55,6 +55,7 @@ import com.wallet.core.primitives.AssetTag import com.wallet.core.primitives.Chain import com.wallet.core.primitives.Currency import com.wallet.core.primitives.FiatRate +import com.wallet.core.primitives.SearchItemType import com.wallet.core.primitives.TransactionType import com.wallet.core.primitives.Wallet import com.wallet.core.primitives.WalletId @@ -88,7 +89,7 @@ private const val TAG = "AssetsRepository" @Singleton class AssetsRepository @Inject constructor( private val assetsDao: AssetsDao, - private val assetsPriorityDao: AssetsPriorityDao, + private val searchPriorityDao: SearchPriorityDao, private val balancesDao: BalancesDao, private val pricesDao: PricesDao, private val sessionRepository: SessionRepository, @@ -265,7 +266,7 @@ class AssetsRepository @Inject constructor( fun search(query: String, tags: List, byAllWallets: Boolean): Flow> { val query = tags.toPriorityQuery(query) return currentWalletId().flatMapLatest { walletId -> - assetsPriorityDao.hasPriorities(query).map { it > 0 }.flatMapLatest { hasPriority -> + searchPriorityDao.hasPriorities(query, SearchItemType.Asset.string).map { it > 0 }.flatMapLatest { hasPriority -> when { byAllWallets && hasPriority -> assetsDao.searchByAllWalletsWithPriority(walletId, query) byAllWallets -> assetsDao.searchByAllWallets(walletId, query) @@ -282,7 +283,7 @@ class AssetsRepository @Inject constructor( val walletChains = wallet.accounts.map { it.chain } val includeChains = byChains.filter { walletChains.contains(it) } val includeAssetIds = byAssets.filter { walletChains.contains(it.chain) } - return assetsPriorityDao.hasPriorities(query).map { it > 0 }.flatMapLatest { hasPriority -> + return searchPriorityDao.hasPriorities(query, SearchItemType.Asset.string).map { it > 0 }.flatMapLatest { hasPriority -> if (hasPriority) { assetsDao.swapSearchWithPriority(wallet.id.id, query, includeChains, includeAssetIds.map { it.toIdentifier() }) } else { diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/UserConfigExt.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/UserConfigExt.kt new file mode 100644 index 0000000000..1dbd96f267 --- /dev/null +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/UserConfigExt.kt @@ -0,0 +1,11 @@ +package com.gemwallet.android.data.repositories.config + +import com.gemwallet.android.ext.hasPerpetualsSupport +import com.gemwallet.android.model.Session +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +fun UserConfig.showPerpetuals(session: Flow): Flow = + combine(session, isPerpetualEnabled()) { current, enabled -> + enabled && current?.wallet?.hasPerpetualsSupport == true + } diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/AssetsModule.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/AssetsModule.kt index 9f72c6ca1d..33c57a9a95 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/AssetsModule.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/AssetsModule.kt @@ -20,8 +20,8 @@ import com.gemwallet.android.data.repositories.stream.StreamObserverService import com.gemwallet.android.data.repositories.stream.StreamSubscriptionService import com.gemwallet.android.data.repositories.wallets.WalletsRepository import com.gemwallet.android.data.service.store.database.AssetsDao -import com.gemwallet.android.data.service.store.database.AssetsPriorityDao import com.gemwallet.android.data.service.store.database.BalancesDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import com.gemwallet.android.data.service.store.database.InAppNotificationsDao import com.gemwallet.android.data.service.store.database.PriceAlertsDao import com.gemwallet.android.data.service.store.database.PricesDao @@ -40,7 +40,7 @@ object AssetsModule { @Singleton fun provideAssetsRepository( assetsDao: AssetsDao, - assetsPriorityDao: AssetsPriorityDao, + searchPriorityDao: SearchPriorityDao, balancesDao: BalancesDao, pricesDao: PricesDao, sessionRepository: SessionRepository, @@ -52,7 +52,7 @@ object AssetsModule { streamSubscriptionService: StreamSubscriptionService, ): AssetsRepository = AssetsRepository( assetsDao = assetsDao, - assetsPriorityDao = assetsPriorityDao, + searchPriorityDao = searchPriorityDao, balancesDao = balancesDao, pricesDao = pricesDao, sessionRepository = sessionRepository, diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/PerpetualModule.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/PerpetualModule.kt index 4aebdfd6b9..c814c8aee5 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/PerpetualModule.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/PerpetualModule.kt @@ -6,6 +6,7 @@ import com.gemwallet.android.data.service.store.database.AssetsDao import com.gemwallet.android.data.service.store.database.BalancesDao import com.gemwallet.android.data.service.store.database.PerpetualDao import com.gemwallet.android.data.service.store.database.PerpetualPositionDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,12 +24,14 @@ object PerpetualModule { perpetualPositionDao: PerpetualPositionDao, assetsDao: AssetsDao, balancesDao: BalancesDao, + searchPriorityDao: SearchPriorityDao, ): PerpetualRepository { return PerpetualRepositoryImpl( perpetualDao = perpetualDao, perpetualPositionDao = perpetualPositionDao, assetsDao = assetsDao, balancesDao = balancesDao, + searchPriorityDao = searchPriorityDao, ) } } diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TokensModule.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TokensModule.kt index f68e0e15c6..94056d69ea 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TokensModule.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TokensModule.kt @@ -1,13 +1,17 @@ package com.gemwallet.android.data.repositories.di +import com.gemwallet.android.application.assets.coordinators.GemSearch import com.gemwallet.android.application.assets.coordinators.SearchAssets import com.gemwallet.android.blockchain.services.TokenService import com.gemwallet.android.cases.tokens.SearchTokensCase import com.gemwallet.android.cases.tokens.SyncAssetPrices +import com.gemwallet.android.data.repositories.perpetual.PerpetualRepository import com.gemwallet.android.data.repositories.tokens.TokensRepository +import com.gemwallet.android.data.repositories.tokens.WalletSearch +import com.gemwallet.android.data.repositories.tokens.WalletSearchTokens import com.gemwallet.android.data.service.store.database.AssetsDao -import com.gemwallet.android.data.service.store.database.AssetsPriorityDao import com.gemwallet.android.data.service.store.database.PricesDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,13 +27,13 @@ object TokensModule { fun provideTokensRepository( assetsDao: AssetsDao, pricesDao: PricesDao, - assetsPriorityDao: AssetsPriorityDao, + searchPriorityDao: SearchPriorityDao, gateway: GemGateway, searchAssets: SearchAssets, ): TokensRepository = TokensRepository( assetsDao = assetsDao, pricesDao = pricesDao, - assetsPriorityDao = assetsPriorityDao, + searchPriorityDao = searchPriorityDao, searchAssets = searchAssets, tokenService = TokenService( gateway = gateway, @@ -40,6 +44,16 @@ object TokensModule { @Singleton fun provideSearchTokensCase(tokensRepository: TokensRepository): SearchTokensCase = tokensRepository + @Provides + @Singleton + @WalletSearch + fun provideWalletSearchTokensCase( + tokensRepository: TokensRepository, + gemSearch: GemSearch, + perpetualRepository: PerpetualRepository, + searchPriorityDao: SearchPriorityDao, + ): SearchTokensCase = WalletSearchTokens(tokensRepository, gemSearch, perpetualRepository, searchPriorityDao) + @Provides @Singleton fun provideSyncAssetPrices(tokensRepository: TokensRepository): SyncAssetPrices = tokensRepository diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/perpetual/PerpetualRepositoryImpl.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/perpetual/PerpetualRepositoryImpl.kt index 85da0ff320..2f3bef0517 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/perpetual/PerpetualRepositoryImpl.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/perpetual/PerpetualRepositoryImpl.kt @@ -4,10 +4,12 @@ import com.gemwallet.android.data.service.store.database.AssetsDao import com.gemwallet.android.data.service.store.database.BalancesDao import com.gemwallet.android.data.service.store.database.PerpetualDao import com.gemwallet.android.data.service.store.database.PerpetualPositionDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import com.gemwallet.android.data.service.store.database.entities.toDB import com.gemwallet.android.data.service.store.database.entities.toDTO import com.gemwallet.android.data.service.store.database.entities.toDto import com.gemwallet.android.data.service.store.database.entities.DbBalance +import com.gemwallet.android.data.service.store.database.entities.DbPerpetualData import com.gemwallet.android.data.service.store.database.entities.toRecord import com.gemwallet.android.ext.toIdentifier import com.wallet.core.primitives.Asset @@ -18,8 +20,11 @@ import com.wallet.core.primitives.PerpetualData import com.wallet.core.primitives.PerpetualId import com.wallet.core.primitives.PerpetualPosition import com.wallet.core.primitives.PerpetualPositionData +import com.wallet.core.primitives.SearchItemType import com.wallet.core.primitives.WalletId +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map class PerpetualRepositoryImpl( @@ -27,6 +32,7 @@ class PerpetualRepositoryImpl( private val perpetualPositionDao: PerpetualPositionDao, private val assetsDao: AssetsDao, private val balancesDao: BalancesDao, + private val searchPriorityDao: SearchPriorityDao, ) : PerpetualRepository { override suspend fun putPerpetuals(items: List) { @@ -34,17 +40,28 @@ class PerpetualRepositoryImpl( perpetualDao.upsert(items.map { it.perpetual.toDB() }) } + @OptIn(ExperimentalCoroutinesApi::class) override fun getPerpetuals(query: String?): Flow> { - val needle = query?.trim().orEmpty() - return perpetualDao.getPerpetualsData().map { items -> - items.mapNotNull { it.toDTO() }.filter { needle.isEmpty() || it.matches(needle) } + val searchQuery = query?.trim().orEmpty() + if (searchQuery.isEmpty()) { + return perpetualDao.getPerpetualsData().toPerpetualData() + } + return searchPriorityDao.hasPriorities(searchQuery, SearchItemType.Perpetual.string).flatMapLatest { hasPriority -> + if (hasPriority > 0) { + perpetualDao.searchWithPriority(searchQuery).toPerpetualData() + } else { + perpetualDao.getPerpetualsData().toPerpetualData() + .map { items -> items.filter { it.matches(searchQuery) } } + } } } - private fun PerpetualData.matches(needle: String): Boolean = - perpetual.name.contains(needle, ignoreCase = true) || - asset.symbol.contains(needle, ignoreCase = true) || - asset.name.contains(needle, ignoreCase = true) + private fun Flow>.toPerpetualData(): Flow> = + map { items -> items.mapNotNull { it.toDTO() } } + + private fun PerpetualData.matches(query: String): Boolean = + perpetual.name.contains(query, ignoreCase = true) || + asset.symbol.contains(query, ignoreCase = true) override fun getPerpetual(perpetualId: PerpetualId): Flow { return perpetualDao.getPerpetual(perpetualId.toIdentifier()).map { it?.toDTO() } diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepository.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepository.kt index 9f67456b30..fa0aa51af0 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepository.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepository.kt @@ -5,12 +5,12 @@ import com.gemwallet.android.blockchain.services.TokenService import com.gemwallet.android.cases.tokens.SearchTokensCase import com.gemwallet.android.cases.tokens.SyncAssetPrices import com.gemwallet.android.data.service.store.database.AssetsDao -import com.gemwallet.android.data.service.store.database.AssetsPriorityDao import com.gemwallet.android.data.service.store.database.PricesDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import com.gemwallet.android.data.service.store.database.entities.toDTO import com.gemwallet.android.data.service.store.database.entities.toRecord import com.gemwallet.android.data.service.store.database.entities.toPriceRecord -import com.gemwallet.android.data.service.store.database.entities.toRecordPriority +import com.gemwallet.android.data.service.store.database.entities.toSearchPriority import com.gemwallet.android.data.service.store.database.entities.toUpdateRecord import com.gemwallet.android.domains.asset.defaultBasic import com.gemwallet.android.ext.toIdentifier @@ -26,7 +26,7 @@ import kotlinx.coroutines.withContext class TokensRepository ( private val assetsDao: AssetsDao, private val pricesDao: PricesDao, - private val assetsPriorityDao: AssetsPriorityDao, + private val searchPriorityDao: SearchPriorityDao, private val searchAssets: SearchAssets, private val tokenService: TokenService, ) : SearchTokensCase, SyncAssetPrices { @@ -36,7 +36,7 @@ class TokensRepository ( return@withContext false } val tokens = try { - searchAssets.search( + searchAssets.searchAssets( query = query, chains = chains, tags = tags, @@ -44,16 +44,19 @@ class TokensRepository ( } catch (_: Throwable) { return@withContext false } - val assets = if (tokens.isEmpty()) { + storeAssets(query, tokens, currency, tags.toPriorityQuery(query)) + } + + internal suspend fun storeAssets(query: String, tokens: List, currency: Currency, priorityQuery: String): Boolean { + return if (tokens.isEmpty()) { val assets = tokenService.search(query) runCatching { assetsDao.insert(assets.map { it.toRecord() }) } - assets + assets.isNotEmpty() } else { updateAssets(tokens, currency) - assetsPriorityDao.put(tokens.toRecordPriority(tags.toPriorityQuery(query))) - tokens + searchPriorityDao.put(tokens.toSearchPriority(priorityQuery)) + true } - assets.isNotEmpty() } override suspend fun search(assetIds: List, currency: Currency): Boolean { diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearch.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearch.kt new file mode 100644 index 0000000000..caffd5503b --- /dev/null +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearch.kt @@ -0,0 +1,7 @@ +package com.gemwallet.android.data.repositories.tokens + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class WalletSearch diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokens.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokens.kt new file mode 100644 index 0000000000..55be637ee3 --- /dev/null +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokens.kt @@ -0,0 +1,45 @@ +package com.gemwallet.android.data.repositories.tokens + +import com.gemwallet.android.application.assets.coordinators.GemSearch +import com.gemwallet.android.cases.tokens.SearchTokensCase +import com.gemwallet.android.data.repositories.perpetual.PerpetualRepository +import com.gemwallet.android.data.service.store.database.SearchPriorityDao +import com.gemwallet.android.data.service.store.database.entities.toSearchPriority +import com.wallet.core.primitives.AssetTag +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualData +import com.wallet.core.primitives.PerpetualMetadata +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class WalletSearchTokens( + private val tokensRepository: TokensRepository, + private val gemSearch: GemSearch, + private val perpetualRepository: PerpetualRepository, + private val searchPriorityDao: SearchPriorityDao, +) : SearchTokensCase by tokensRepository { + + override suspend fun search(query: String, currency: Currency, chains: List, tags: List): Boolean = withContext(Dispatchers.IO) { + if (query.isEmpty() && tags.isEmpty()) { + return@withContext false + } + val response = try { + gemSearch.search(query = query, chains = chains, tags = tags) + } catch (_: Throwable) { + return@withContext false + } + val priorityQuery = tags.toPriorityQuery(query) + val hasAssets = tokensRepository.storeAssets(query, response.assets, currency, priorityQuery) + val perpetuals = if (tags.isEmpty()) response.perpetuals else emptyList() + if (perpetuals.isNotEmpty()) { + runCatching { + perpetualRepository.putPerpetuals( + perpetuals.map { PerpetualData(perpetual = it.perpetual, asset = it.asset, metadata = PerpetualMetadata(isPinned = false)) } + ) + searchPriorityDao.put(perpetuals.toSearchPriority(priorityQuery)) + } + } + hasAssets || perpetuals.isNotEmpty() + } +} diff --git a/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepositoryTest.kt b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepositoryTest.kt index e8bded738f..ead3eed438 100644 --- a/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepositoryTest.kt +++ b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepositoryTest.kt @@ -8,9 +8,9 @@ import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.repositories.stream.StreamSubscriptionService import com.gemwallet.android.cases.tokens.SearchTokensCase import com.gemwallet.android.data.service.store.database.AssetsDao -import com.gemwallet.android.data.service.store.database.AssetsPriorityDao import com.gemwallet.android.data.service.store.database.BalancesDao import com.gemwallet.android.data.service.store.database.PricesDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import com.gemwallet.android.data.service.store.database.entities.DbAsset import com.gemwallet.android.data.service.store.database.entities.DbAssetBasicUpdate import com.gemwallet.android.data.service.store.database.entities.DbFiatRate @@ -73,7 +73,7 @@ import uniffi.gemstone.assetDefaultRank class AssetsRepositoryTest { private val assetsDao = mockk(relaxed = true) - private val assetsPriorityDao = mockk(relaxed = true) + private val searchPriorityDao = mockk(relaxed = true) private val balancesDao = mockk(relaxed = true) private val pricesDao = mockk(relaxed = true) private val sessionRepository = mockk() @@ -89,7 +89,7 @@ class AssetsRepositoryTest { private fun createSubject() = AssetsRepository( assetsDao = assetsDao, - assetsPriorityDao = assetsPriorityDao, + searchPriorityDao = searchPriorityDao, balancesDao = balancesDao, pricesDao = pricesDao, sessionRepository = sessionRepository, @@ -486,7 +486,7 @@ class AssetsRepositoryTest { fun swapSearch_includesEnabledHiddenAndUnlinkedAssets() = runBlocking { every { getChangedTransactions.getChangedTransactions() } returns emptyFlow() every { sessionRepository.session() } returns sessionFlow - every { assetsPriorityDao.hasPriorities("") } returns flowOf(0) + every { searchPriorityDao.hasPriorities("", "asset") } returns flowOf(0) val wallet = mockWallet( id = "wallet-1", @@ -553,7 +553,7 @@ class AssetsRepositoryTest { fun swapSearch_usesPriorityDaoAndPreservesOrderWhenPrioritiesExist() = runBlocking { every { getChangedTransactions.getChangedTransactions() } returns emptyFlow() every { sessionRepository.session() } returns sessionFlow - every { assetsPriorityDao.hasPriorities("usd") } returns flowOf(2) + every { searchPriorityDao.hasPriorities("usd", "asset") } returns flowOf(2) val wallet = mockWallet( id = "wallet-1", diff --git a/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepositoryTest.kt b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepositoryTest.kt index be3e4565da..28f975250b 100644 --- a/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepositoryTest.kt +++ b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepositoryTest.kt @@ -3,11 +3,11 @@ package com.gemwallet.android.data.repositories.tokens import com.gemwallet.android.application.assets.coordinators.SearchAssets import com.gemwallet.android.blockchain.services.TokenService import com.gemwallet.android.data.service.store.database.AssetsDao -import com.gemwallet.android.data.service.store.database.AssetsPriorityDao import com.gemwallet.android.data.service.store.database.PricesDao +import com.gemwallet.android.data.service.store.database.SearchPriorityDao import com.gemwallet.android.data.service.store.database.entities.DbAssetBasicUpdate -import com.gemwallet.android.data.service.store.database.entities.DbAssetPriority import com.gemwallet.android.data.service.store.database.entities.DbFiatRate +import com.gemwallet.android.data.service.store.database.entities.DbSearchPriority import com.gemwallet.android.data.service.store.database.entities.DbPrice import com.gemwallet.android.ext.toIdentifier import com.gemwallet.android.testkit.mockAsset @@ -31,14 +31,14 @@ class TokensRepositoryTest { private val assetsDao = mockk(relaxed = true) private val pricesDao = mockk(relaxed = true) - private val assetsPriorityDao = mockk(relaxed = true) + private val searchPriorityDao = mockk(relaxed = true) private val searchAssets = mockk() private val tokenService = mockk(relaxed = true) private val subject = TokensRepository( assetsDao = assetsDao, pricesDao = pricesDao, - assetsPriorityDao = assetsPriorityDao, + searchPriorityDao = searchPriorityDao, searchAssets = searchAssets, tokenService = tokenService, ) @@ -47,7 +47,7 @@ class TokensRepositoryTest { fun search_usesSearchAssetsAndStoresPriority() = runTest { val asset = mockAssetBasic() coEvery { - searchAssets.search( + searchAssets.searchAssets( query = "btc", chains = listOf(Chain.Bitcoin), tags = listOf(AssetTag.Trending), @@ -61,17 +61,17 @@ class TokensRepositoryTest { chains = listOf(Chain.Bitcoin), tags = listOf(AssetTag.Trending), ) - val priorities = slot>() + val priorities = slot>() assertTrue(result) coVerify { - searchAssets.search( + searchAssets.searchAssets( query = "btc", chains = listOf(Chain.Bitcoin), tags = listOf(AssetTag.Trending), ) } - coVerify { assetsPriorityDao.put(capture(priorities)) } + coVerify { searchPriorityDao.put(capture(priorities)) } assertEquals("btc::trending", priorities.captured.single().query) } @@ -80,7 +80,7 @@ class TokensRepositoryTest { val firstResult = mockAssetBasic(asset = mockAssetEthereum(), rank = 10) val secondResult = mockAssetBasic(asset = mockAsset(), rank = 999) coEvery { - searchAssets.search( + searchAssets.searchAssets( query = "usdt arbitrum", chains = emptyList(), tags = emptyList(), @@ -95,11 +95,11 @@ class TokensRepositoryTest { tags = emptyList(), ) - val priorities = slot>() - coVerify { assetsPriorityDao.put(capture(priorities)) } + val priorities = slot>() + coVerify { searchPriorityDao.put(capture(priorities)) } val captured = priorities.captured - assertEquals(listOf(firstResult.asset.id.toIdentifier(), secondResult.asset.id.toIdentifier()), captured.map { it.assetId }) + assertEquals(listOf(firstResult.asset.id.toIdentifier(), secondResult.asset.id.toIdentifier()), captured.map { it.itemId }) assertTrue("first response item must outrank later items", captured[0].priority < captured[1].priority) } diff --git a/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokensTest.kt b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokensTest.kt new file mode 100644 index 0000000000..3b0847a845 --- /dev/null +++ b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokensTest.kt @@ -0,0 +1,71 @@ +package com.gemwallet.android.data.repositories.tokens + +import com.gemwallet.android.application.assets.coordinators.GemSearch +import com.gemwallet.android.data.repositories.perpetual.PerpetualRepository +import com.gemwallet.android.data.service.store.database.SearchPriorityDao +import com.gemwallet.android.testkit.mockAsset +import com.gemwallet.android.testkit.mockAssetBasic +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.Perpetual +import com.wallet.core.primitives.PerpetualId +import com.wallet.core.primitives.PerpetualProvider +import com.wallet.core.primitives.PerpetualSearchData +import com.wallet.core.primitives.SearchResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class WalletSearchTokensTest { + + private val tokensRepository = mockk(relaxed = true) + private val gemSearch = mockk() + private val perpetualRepository = mockk(relaxed = true) + private val searchPriorityDao = mockk(relaxed = true) + + private val subject = WalletSearchTokens( + tokensRepository = tokensRepository, + gemSearch = gemSearch, + perpetualRepository = perpetualRepository, + searchPriorityDao = searchPriorityDao, + ) + + @Test + fun search_ingestsPerpetualsAndStoresPerpPriority() = runTest { + val perpAsset = mockAsset() + val perpetual = Perpetual( + id = PerpetualId(provider = PerpetualProvider.Hypercore, symbol = "BTC"), + name = "Bitcoin", + provider = PerpetualProvider.Hypercore, + assetId = perpAsset.id, + identifier = "0", + price = 1.0, + pricePercentChange24h = 0.0, + openInterest = 0.0, + volume24h = 1.0, + funding = 0.0, + maxLeverage = 1u, + isIsolatedOnly = false, + ) + coEvery { + gemSearch.search(query = "btc", chains = emptyList(), tags = emptyList()) + } returns SearchResponse( + assets = listOf(mockAssetBasic()), + perpetuals = listOf(PerpetualSearchData(perpetual = perpetual, asset = perpAsset)), + nfts = emptyList(), + ) + + val result = subject.search( + query = "btc", + currency = Currency.USD, + chains = emptyList(), + tags = emptyList(), + ) + + assertTrue(result) + coVerify { perpetualRepository.putPerpetuals(any()) } + coVerify { searchPriorityDao.put(match { priorities -> priorities.any { it.type == "perpetual" } }) } + } +} From 6249705c4c839a0f2ba2e8cfc572e8d438e491f1 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:33:03 +0300 Subject: [PATCH 05/11] Extract shared asset row badge and section header helpers Make assetRows public, add getAssetBadge, and support clickable section headers via nullable SubheaderItem onClick. --- .../navigation/AssetsManageNavigation.kt | 12 ------- .../asset_select/presents/views/AssetBadge.kt | 7 ++++ .../presents/views/AssetSelectScene.kt | 36 +++++++++++++++---- .../presents/views/AssetsManageScreen.kt | 4 --- .../viewmodels/BaseAssetSelectViewModel.kt | 2 +- .../ui/components/list_item/SubheaderItem.kt | 8 ++--- 6 files changed, 41 insertions(+), 28 deletions(-) create mode 100644 android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetBadge.kt diff --git a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/navigation/AssetsManageNavigation.kt b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/navigation/AssetsManageNavigation.kt index b691f072e9..6a3a48a069 100644 --- a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/navigation/AssetsManageNavigation.kt +++ b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/navigation/AssetsManageNavigation.kt @@ -3,16 +3,12 @@ package com.gemwallet.android.features.asset_select.presents.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.gemwallet.android.features.asset_select.presents.views.AssetsManageScreen -import com.gemwallet.android.features.asset_select.presents.views.AssetsSearchScreen import com.wallet.core.primitives.AssetId import kotlinx.serialization.Serializable @Serializable data object AssetsManageRoute : NavKey -@Serializable -data object AssetsSearchRoute : NavKey - fun EntryProviderScope.assetsManageScreen( onAddAsset: () -> Unit, onAssetClick: (AssetId) -> Unit, @@ -25,12 +21,4 @@ fun EntryProviderScope.assetsManageScreen( onCancel = onCancel, ) } - - entry { - AssetsSearchScreen( - onAddAsset = onAddAsset, - onAssetClick = onAssetClick, - onCancel = onCancel, - ) - } } diff --git a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetBadge.kt b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetBadge.kt new file mode 100644 index 0000000000..3ba5109661 --- /dev/null +++ b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetBadge.kt @@ -0,0 +1,7 @@ +package com.gemwallet.android.features.asset_select.presents.views + +import com.gemwallet.android.ui.components.list_item.AssetItemUIModel + +fun getAssetBadge(item: AssetItemUIModel): String { + return if (item.asset.symbol == item.asset.name) "" else item.asset.symbol +} diff --git a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetSelectScene.kt b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetSelectScene.kt index ff5d708faa..39691023df 100644 --- a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetSelectScene.kt +++ b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetSelectScene.kt @@ -173,6 +173,10 @@ fun AssetSelectScene( onSelectRecent: ((AssetId) -> Unit)? = null, onOpenRecentsSheet: (() -> Unit)? = null, contextActions: AssetContextActions = AssetContextActions.Empty, + pinnedPerpetualsContent: (LazyListScope.() -> Unit)? = null, + perpetualsContent: (LazyListScope.() -> Unit)? = null, + assetsHeaderRes: Int? = null, + onAssetsHeaderClick: (() -> Unit)? = null, ) { val listState = rememberLazyListState() var isReturnToTop by remember { mutableStateOf(false) } @@ -242,7 +246,17 @@ fun AssetSelectScene( } recent(recent, onSelectRecent, onOpenRecentsSheet) assets(popular, AssetsGroupType.Popular, onSelect, support, titleBadge, itemTrailing, longPressedAsset, contextActions) - assets(pinned, AssetsGroupType.Pined, onSelect, support, titleBadge, itemTrailing, longPressedAsset, contextActions) + if (pinned.isNotEmpty() || pinnedPerpetualsContent != null) { + item { PinnedAssetsHeaderItem(AssetsGroupType.Pined) } + pinnedPerpetualsContent?.invoke(this) + assetRows(pinned, onSelect, support, titleBadge, itemTrailing, longPressedAsset, contextActions) + } + perpetualsContent?.invoke(this) + if (assetsHeaderRes != null && unpinned.isNotEmpty()) { + item { + SubheaderItem(assetsHeaderRes, onAssetsHeaderClick) + } + } assets(unpinned, AssetsGroupType.None, onSelect, support, titleBadge, itemTrailing, longPressedAsset, contextActions) loading(state) notFound(state = state, onAddAsset = onAddAsset, isAddAvailable = isAddAvailable) @@ -276,6 +290,18 @@ private fun LazyListScope.assets( item { PinnedAssetsHeaderItem(group) } + assetRows(items, onSelect, support, titleBadge, itemTrailing, longPressedAsset, contextActions) +} + +fun LazyListScope.assetRows( + items: List, + onSelect: ((AssetId) -> Unit)?, + support: ((AssetItemUIModel) -> (@Composable () -> Unit)?)?, + titleBadge: (AssetItemUIModel) -> String?, + itemTrailing: (@Composable (AssetItemUIModel) -> Unit)?, + longPressedAsset: MutableState, + contextActions: AssetContextActions, +) { itemsPositioned(items) { position, item -> AssetSelectRow( position = position, @@ -291,7 +317,7 @@ private fun LazyListScope.assets( } @Composable -private fun AssetSelectRow( +fun AssetSelectRow( position: ListPosition, item: AssetItemUIModel, support: ((AssetItemUIModel) -> (@Composable () -> Unit)?)?, @@ -364,11 +390,7 @@ private fun LazyListScope.recent( return } item { - if (onOpenRecentsSheet == null) { - SubheaderItem(R.string.recent_activity_title) - } else { - SubheaderItem(R.string.recent_activity_title, onClick = onOpenRecentsSheet) - } + SubheaderItem(R.string.recent_activity_title, onOpenRecentsSheet) } item { LazyRow( diff --git a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetsManageScreen.kt b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetsManageScreen.kt index 8a49976712..0e71dcd80d 100644 --- a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetsManageScreen.kt +++ b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetsManageScreen.kt @@ -90,7 +90,3 @@ fun AssetsManageScreen( contextActions = AssetContextActions.Empty, ) } - -fun getAssetBadge(item: AssetItemUIModel): String { - return if (item.asset.symbol == item.asset.name) "" else item.asset.symbol -} diff --git a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt index d35071966e..b8a39ee3a8 100644 --- a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt +++ b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt @@ -70,7 +70,7 @@ open class BaseAssetSelectViewModel( .map { session -> session?.wallet?.accounts?.map { it.chain } ?: emptyList() } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - private val currentQuery = snapshotFlow { queryState.text.toString() } + protected val currentQuery = snapshotFlow { queryState.text.toString() } .stateIn(viewModelScope, SharingStarted.Eagerly, "") private val filters = combine( diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/SubheaderItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/SubheaderItem.kt index 80c3b4a2bd..dce0514381 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/SubheaderItem.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/SubheaderItem.kt @@ -42,8 +42,9 @@ fun SubheaderItem(title: String, modifier: Modifier = Modifier) { } @Composable -fun SubheaderItem(@StringRes title: Int, onClick: () -> Unit) { - SubheaderItem(stringResource(title), onClick) +fun SubheaderItem(@StringRes title: Int, onClick: (() -> Unit)?) { + val text = stringResource(title) + if (onClick == null) SubheaderItem(text) else SubheaderItem(text, onClick) } @Composable @@ -52,8 +53,7 @@ fun SubheaderItem(title: String, onClick: () -> Unit) { Row( modifier = Modifier .clip(RoundedCornerShape(paddingHalfSmall)) - .clickable(onClick = onClick) - .padding(paddingHalfSmall), + .clickable(onClick = onClick), verticalAlignment = Alignment.CenterVertically, ) { Text( From 89853b8f1186af2d07a84fb68ec9d3d87dc2b1cb Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:33:03 +0300 Subject: [PATCH 06/11] Add wallet search and results screens with perpetuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the iOS WalletSearchScene/AssetsResultsScene flow — sections, preview limits, onAction routing, perpetuals gating — and replace the old AssetsSearchScreen. --- .../android/ui/navigation/RootRoute.kt | 7 +- .../android/ui/navigation/WalletNavGraph.kt | 15 ++ .../ui/navigation/routes/WalletSearch.kt | 36 +++++ .../presents/views/AssetsSearchScreen.kt | 87 ---------- .../features/assets/presents/build.gradle.kts | 2 + .../assets/views/AssetsResultsScreen.kt | 69 ++++++++ .../assets/views/WalletSearchAction.kt | 13 ++ .../assets/views/WalletSearchScreen.kt | 151 ++++++++++++++++++ .../assets/viewmodels/build.gradle.kts | 2 + .../viewmodels/AssetsResultsViewModel.kt | 58 +++++++ .../assets/viewmodels/WalletSearchLimits.kt | 9 ++ .../viewmodels/WalletSearchViewModel.kt | 146 +++++++++++++++++ .../viewmodels/PerpetualsPreviewViewModel.kt | 11 +- .../ui/models/navigation/RouteArgument.kt | 2 + 14 files changed, 511 insertions(+), 97 deletions(-) create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/WalletSearch.kt delete mode 100644 android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetsSearchScreen.kt create mode 100644 android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsResultsScreen.kt create mode 100644 android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchAction.kt create mode 100644 android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchScreen.kt create mode 100644 android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt create mode 100644 android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchLimits.kt create mode 100644 android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchViewModel.kt diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt index ffd087f5e4..18319b12c1 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt @@ -8,7 +8,8 @@ import androidx.compose.runtime.remember import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.gemwallet.android.features.asset_select.presents.navigation.AssetsManageRoute -import com.gemwallet.android.features.asset_select.presents.navigation.AssetsSearchRoute +import com.gemwallet.android.ui.navigation.routes.AssetsResultsRoute +import com.gemwallet.android.ui.navigation.routes.WalletSearchRoute import com.gemwallet.android.features.create_wallet.navigation.CreateWalletAlertRoute import com.gemwallet.android.features.create_wallet.navigation.CreateWalletRoute import com.gemwallet.android.features.import_wallet.navigation.ImportChainWalletRoute @@ -74,6 +75,7 @@ import com.gemwallet.android.ui.navigation.routes.WalletSecurityReminderRoute import com.gemwallet.android.ui.navigation.routes.WalletsRoute import com.gemwallet.android.ext.toIdentifier import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.AssetTag import com.wallet.core.primitives.NFTAssetId import com.wallet.core.primitives.TransactionId import com.wallet.core.primitives.WalletId @@ -170,7 +172,8 @@ class WalletNavigator( fun openWallets() = push(WalletsRoute) fun openAcceptTerms(destination: AcceptTermsDestination) = push(AcceptTermsRoute(destination)) fun openAssetsManage() = push(AssetsManageRoute) - fun openAssetsSearch() = push(AssetsSearchRoute) + fun openAssetsSearch() = push(WalletSearchRoute) + fun openAssetsResults(query: String, tag: AssetTag?) = push(AssetsResultsRoute(query, tag)) fun openCreateWalletRules() = push(CreateWalletAlertRoute) fun openCreateWallet() = push(CreateWalletRoute) fun openImportWallet() = push(ImportSelectTypeRoute) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt index 074ac3e34b..fb7cc3530f 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt @@ -22,6 +22,7 @@ import com.gemwallet.android.ui.models.actions.AmountTransactionAction import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction import com.gemwallet.android.features.activities.presents.details.TransactionDetailsAction import com.gemwallet.android.features.asset_select.presents.navigation.assetsManageScreen +import com.gemwallet.android.features.assets.views.WalletSearchAction import com.gemwallet.android.features.create_wallet.navigation.createWalletScreen import com.gemwallet.android.features.import_wallet.navigation.importWalletScreen import com.gemwallet.android.features.onboarding.OnboardingRoute @@ -51,6 +52,7 @@ import com.gemwallet.android.ui.navigation.routes.swap import com.gemwallet.android.ui.navigation.routes.swapSelect import com.gemwallet.android.ui.navigation.routes.transactionDetailsScreen import com.gemwallet.android.ui.navigation.routes.walletScreen +import com.gemwallet.android.ui.navigation.routes.walletSearchScreen import com.gemwallet.android.ui.navigation.routes.walletsScreen import com.wallet.core.primitives.WalletId @@ -86,6 +88,19 @@ fun WalletNavGraph( onCancel = onCancel, ) + walletSearchScreen( + onAction = { action -> + when (action) { + WalletSearchAction.AddAsset -> navigator.openAddAsset() + WalletSearchAction.Cancel -> onCancel() + WalletSearchAction.OpenPerpetuals -> navigator.openPerpetuals() + is WalletSearchAction.OpenAsset -> navigator.openAsset(action.assetId) + is WalletSearchAction.OpenPerpetual -> navigator.openPerpetualDetails(action.assetId) + is WalletSearchAction.ShowAllAssets -> navigator.openAssetsResults(action.query, action.tag) + } + }, + ) + assetScreen( onCancel = onCancel, onTransfer = navigator::openRecipient, diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/WalletSearch.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/WalletSearch.kt new file mode 100644 index 0000000000..a14bf7dea0 --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/WalletSearch.kt @@ -0,0 +1,36 @@ +package com.gemwallet.android.ui.navigation.routes + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.gemwallet.android.features.assets.views.AssetsResultsScreen +import com.gemwallet.android.features.assets.views.WalletSearchAction +import com.gemwallet.android.features.assets.views.WalletSearchScreen +import com.gemwallet.android.ui.models.navigation.RouteArgument +import com.gemwallet.android.ui.navigation.routeArguments +import com.wallet.core.primitives.AssetTag +import kotlinx.serialization.Serializable + +@Serializable +data object WalletSearchRoute : NavKey + +@Serializable +data class AssetsResultsRoute(val query: String, val tag: AssetTag?) : NavKey + +fun EntryProviderScope.walletSearchScreen( + onAction: (WalletSearchAction) -> Unit, +) { + entry { + WalletSearchScreen(onAction = onAction) + } + + entry( + metadata = { key -> + routeArguments( + RouteArgument.Query to key.query, + RouteArgument.Tag to key.tag?.string, + ) + }, + ) { + AssetsResultsScreen(onAction = onAction) + } +} diff --git a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetsSearchScreen.kt b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetsSearchScreen.kt deleted file mode 100644 index 6ce50dc891..0000000000 --- a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetsSearchScreen.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.gemwallet.android.features.asset_select.presents.views - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.gemwallet.android.ui.components.SearchBar -import com.gemwallet.android.ui.components.list_item.AssetContextActions -import com.gemwallet.android.ui.components.list_item.AssetItemUIModel -import com.gemwallet.android.ui.components.list_item.assetPriceSupport -import com.gemwallet.android.ui.components.list_item.getBalanceInfo -import com.gemwallet.android.ui.components.list_item.listItem -import com.gemwallet.android.ui.models.ListPosition -import com.gemwallet.android.features.asset_select.viewmodels.AssetSelectViewModel -import com.gemwallet.android.features.asset_select.viewmodels.RecentsSheetViewModel -import com.gemwallet.android.model.RecentType -import com.wallet.core.primitives.AssetId -import kotlinx.collections.immutable.toImmutableList - -@Composable -fun AssetsSearchScreen( - onAddAsset: () -> Unit, - onAssetClick: (AssetId) -> Unit, - onCancel: () -> Unit, - viewModel: AssetSelectViewModel = hiltViewModel(), - recentsViewModel: RecentsSheetViewModel = hiltViewModel(), -) { - val isAddAssetAvailable by viewModel.isAddAssetAvailable.collectAsStateWithLifecycle() - val uiStates by viewModel.uiState.collectAsStateWithLifecycle() - val pinned by viewModel.pinned.collectAsStateWithLifecycle() - val unpinned by viewModel.unpinned.collectAsStateWithLifecycle() - val recent by viewModel.recent.collectAsStateWithLifecycle() - - val selectedTag by viewModel.selectedTag.collectAsStateWithLifecycle() - - val contextActions = remember(viewModel) { - AssetContextActions( - onTogglePin = viewModel::onTogglePin, - onAddToWallet = { id -> viewModel.onChangeVisibility(id, true) }, - ) - } - - val selectAsset: (AssetId) -> Unit = { id -> - viewModel.updateRecent(id, RecentType.Search) - onAssetClick(id) - } - - AssetSelectScene( - title = { - SearchBar( - query = viewModel.queryState, - modifier = Modifier.listItem(ListPosition.Single, paddingHorizontal = 0.dp), - ) - }, - titleBadge = ::getAssetBadge, - support = { assetPriceSupport(it.price) }, - query = viewModel.queryState, - selectedTag = selectedTag, - tags = viewModel.getTags(), - pinned = pinned, - popular = emptyList().toImmutableList(), - unpinned = unpinned, - recent = recent, - state = uiStates, - isAddAvailable = isAddAssetAvailable, - availableChains = emptyList(), - chainsFilter = emptyList(), - balanceFilter = false, - searchable = false, - onChainFilter = {}, - onBalanceFilter = {}, - onClearFilters = {}, - onCancel = onCancel, - onAddAsset = if (isAddAssetAvailable) onAddAsset else null, - onSelect = selectAsset, - onSelectRecent = onAssetClick, - onOpenRecentsSheet = { recentsViewModel.show(filters = viewModel.assetFilters()) }, - onTagSelect = viewModel::onTagSelect, - itemTrailing = { asset -> getBalanceInfo(asset)() }, - contextActions = contextActions, - ) - - RecentsSheetHost(viewModel = recentsViewModel, onSelect = onAssetClick) -} diff --git a/android/features/assets/presents/build.gradle.kts b/android/features/assets/presents/build.gradle.kts index bb1b2c3828..fc65ecafbf 100644 --- a/android/features/assets/presents/build.gradle.kts +++ b/android/features/assets/presents/build.gradle.kts @@ -54,6 +54,8 @@ android { dependencies { implementation(project(":ui")) implementation(project(":features:assets:viewmodels")) + implementation(project(":features:asset_select:presents")) + implementation(project(":features:asset_select:viewmodels")) implementation(project(":features:update_app:presents")) implementation(project(":features:banner:presents")) implementation(project(":features:perpetual:presents")) diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsResultsScreen.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsResultsScreen.kt new file mode 100644 index 0000000000..4437781781 --- /dev/null +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsResultsScreen.kt @@ -0,0 +1,69 @@ +package com.gemwallet.android.features.assets.views + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.asset_select.presents.views.assetRows +import com.gemwallet.android.features.asset_select.presents.views.getAssetBadge +import com.gemwallet.android.features.assets.viewmodels.AssetsResultsViewModel +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.list_item.AssetContextActions +import com.gemwallet.android.ui.components.list_item.PinnedAssetsHeaderItem +import com.gemwallet.android.ui.components.list_item.assetPriceSupport +import com.gemwallet.android.ui.components.list_item.getBalanceInfo +import com.gemwallet.android.ui.components.screen.Scene +import com.gemwallet.android.ui.models.AssetsGroupType +import com.wallet.core.primitives.AssetId + +@Composable +fun AssetsResultsScreen( + onAction: (WalletSearchAction) -> Unit, + viewModel: AssetsResultsViewModel = hiltViewModel(), +) { + val pinned by viewModel.pinned.collectAsStateWithLifecycle() + val cappedAssets by viewModel.cappedAssets.collectAsStateWithLifecycle() + val longPressedAsset = remember { mutableStateOf(null) } + val onAssetClick: (AssetId) -> Unit = { onAction(WalletSearchAction.OpenAsset(it)) } + val contextActions = remember(viewModel) { + AssetContextActions( + onTogglePin = viewModel::onTogglePin, + onAddToWallet = { id -> viewModel.onChangeVisibility(id, true) }, + ) + } + + Scene( + title = stringResource(id = R.string.assets_title), + onClose = { onAction(WalletSearchAction.Cancel) }, + ) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + if (pinned.isNotEmpty()) { + item { PinnedAssetsHeaderItem(AssetsGroupType.Pined) } + assetRows( + items = pinned, + onSelect = onAssetClick, + support = { assetPriceSupport(it.price) }, + titleBadge = ::getAssetBadge, + itemTrailing = { getBalanceInfo(it)() }, + longPressedAsset = longPressedAsset, + contextActions = contextActions, + ) + } + assetRows( + items = cappedAssets, + onSelect = onAssetClick, + support = { assetPriceSupport(it.price) }, + titleBadge = ::getAssetBadge, + itemTrailing = { getBalanceInfo(it)() }, + longPressedAsset = longPressedAsset, + contextActions = contextActions, + ) + } + } +} diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchAction.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchAction.kt new file mode 100644 index 0000000000..6d2254175d --- /dev/null +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchAction.kt @@ -0,0 +1,13 @@ +package com.gemwallet.android.features.assets.views + +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.AssetTag + +sealed interface WalletSearchAction { + data object AddAsset : WalletSearchAction + data object Cancel : WalletSearchAction + data object OpenPerpetuals : WalletSearchAction + data class OpenAsset(val assetId: AssetId) : WalletSearchAction + data class OpenPerpetual(val assetId: AssetId) : WalletSearchAction + data class ShowAllAssets(val query: String, val tag: AssetTag?) : WalletSearchAction +} diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchScreen.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchScreen.kt new file mode 100644 index 0000000000..1f1afcd21d --- /dev/null +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchScreen.kt @@ -0,0 +1,151 @@ +package com.gemwallet.android.features.assets.views + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.ext.toIdentifier +import com.gemwallet.android.features.asset_select.presents.views.AssetSelectScene +import com.gemwallet.android.features.asset_select.presents.views.RecentsSheetHost +import com.gemwallet.android.features.asset_select.presents.views.getAssetBadge +import com.gemwallet.android.features.asset_select.viewmodels.RecentsSheetViewModel +import com.gemwallet.android.features.assets.viewmodels.WalletSearchViewModel +import com.gemwallet.android.features.perpetual.views.components.PerpetualItem +import com.gemwallet.android.model.RecentType +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.SearchBar +import com.gemwallet.android.ui.components.list_item.AssetContextActions +import com.gemwallet.android.ui.components.list_item.AssetItemUIModel +import com.gemwallet.android.ui.components.list_item.SubheaderItem +import com.gemwallet.android.ui.components.list_item.assetPriceSupport +import com.gemwallet.android.ui.components.list_item.getBalanceInfo +import com.gemwallet.android.ui.components.list_item.listItem +import com.gemwallet.android.ui.components.list_item.property.itemsPositioned +import com.gemwallet.android.ui.models.ListPosition +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.PerpetualId +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun WalletSearchScreen( + onAction: (WalletSearchAction) -> Unit, + viewModel: WalletSearchViewModel = hiltViewModel(), + recentsViewModel: RecentsSheetViewModel = hiltViewModel(), +) { + val isAddAssetAvailable by viewModel.isAddAssetAvailable.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val pinned by viewModel.pinned.collectAsStateWithLifecycle() + val previewAssets by viewModel.previewAssets.collectAsStateWithLifecycle() + val hasMoreAssets by viewModel.hasMoreAssets.collectAsStateWithLifecycle() + val recent by viewModel.recent.collectAsStateWithLifecycle() + val selectedTag by viewModel.selectedTag.collectAsStateWithLifecycle() + val previewPerpetuals by viewModel.previewPerpetuals.collectAsStateWithLifecycle() + val hasMorePerpetuals by viewModel.hasMorePerpetuals.collectAsStateWithLifecycle() + val pinnedPerpetuals by viewModel.pinnedPerpetuals.collectAsStateWithLifecycle() + val perpetualRecentIds by viewModel.perpetualRecentIds.collectAsStateWithLifecycle() + + val longPressedPerpetual = remember { mutableStateOf(null) } + + val onRecentClick: (AssetId) -> Unit = { assetId -> + onAction(if (assetId.toIdentifier() in perpetualRecentIds) WalletSearchAction.OpenPerpetual(assetId) else WalletSearchAction.OpenAsset(assetId)) + } + + val contextActions = remember(viewModel) { + AssetContextActions( + onTogglePin = viewModel::onPinAsset, + onAddToWallet = { id -> viewModel.onChangeVisibility(id, true) }, + ) + } + + val selectAsset: (AssetId) -> Unit = { id -> + viewModel.updateRecent(id, RecentType.Search) + onAction(WalletSearchAction.OpenAsset(id)) + } + + val onPerpetualSelect: (AssetId) -> Unit = { assetId -> + viewModel.onOpenPerpetual(assetId) + onAction(WalletSearchAction.OpenPerpetual(assetId)) + } + + val pinnedPerpetualsContent: (LazyListScope.() -> Unit)? = if (pinnedPerpetuals.isNotEmpty()) { + { + itemsPositioned(pinnedPerpetuals) { position, item -> + PerpetualItem( + item = item, + listPosition = position, + longPressState = longPressedPerpetual, + onTogglePin = viewModel::onTogglePerpetualPin, + onClick = onPerpetualSelect, + ) + } + } + } else { + null + } + + val perpetualsContent: (LazyListScope.() -> Unit)? = if (previewPerpetuals.isNotEmpty()) { + { + item { + SubheaderItem(R.string.perpetuals_title, if (hasMorePerpetuals) ({ onAction(WalletSearchAction.OpenPerpetuals) }) else null) + } + itemsPositioned(previewPerpetuals) { position, item -> + PerpetualItem( + item = item, + listPosition = position, + longPressState = longPressedPerpetual, + onTogglePin = viewModel::onTogglePerpetualPin, + onClick = onPerpetualSelect, + ) + } + } + } else { + null + } + + AssetSelectScene( + title = { + SearchBar( + query = viewModel.queryState, + modifier = Modifier.listItem(ListPosition.Single, paddingHorizontal = 0.dp), + ) + }, + titleBadge = ::getAssetBadge, + support = { assetPriceSupport(it.price) }, + query = viewModel.queryState, + selectedTag = selectedTag, + tags = viewModel.getTags(), + pinned = pinned, + popular = emptyList().toImmutableList(), + unpinned = previewAssets.toImmutableList(), + recent = recent, + state = state, + isAddAvailable = isAddAssetAvailable, + searchable = false, + onChainFilter = {}, + onBalanceFilter = {}, + onClearFilters = {}, + onCancel = { onAction(WalletSearchAction.Cancel) }, + onAddAsset = if (isAddAssetAvailable) ({ onAction(WalletSearchAction.AddAsset) }) else null, + onSelect = selectAsset, + onSelectRecent = onRecentClick, + onOpenRecentsSheet = { recentsViewModel.show(filters = viewModel.assetFilters()) }, + onTagSelect = viewModel::onTagSelect, + itemTrailing = { asset -> getBalanceInfo(asset)() }, + contextActions = contextActions, + pinnedPerpetualsContent = pinnedPerpetualsContent, + perpetualsContent = perpetualsContent, + assetsHeaderRes = R.string.assets_title, + onAssetsHeaderClick = if (hasMoreAssets) { + { onAction(WalletSearchAction.ShowAllAssets(viewModel.queryState.text.toString(), selectedTag)) } + } else { + null + }, + ) + + RecentsSheetHost(viewModel = recentsViewModel, onSelect = onRecentClick) +} diff --git a/android/features/assets/viewmodels/build.gradle.kts b/android/features/assets/viewmodels/build.gradle.kts index d258016feb..2bf3ab05b8 100644 --- a/android/features/assets/viewmodels/build.gradle.kts +++ b/android/features/assets/viewmodels/build.gradle.kts @@ -50,6 +50,8 @@ android { dependencies { api(project(":ui-models")) implementation(project(":ui")) + implementation(project(":data:repositories")) + implementation(project(":features:asset_select:viewmodels")) implementation(libs.hilt.android) ksp(libs.hilt.compiler) diff --git a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt new file mode 100644 index 0000000000..c5563ab090 --- /dev/null +++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt @@ -0,0 +1,58 @@ +package com.gemwallet.android.features.assets.viewmodels + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.gemwallet.android.application.asset_select.coordinators.GetRecentAssets +import com.gemwallet.android.application.asset_select.coordinators.SearchSelectAssets +import com.gemwallet.android.application.asset_select.coordinators.SwitchAssetVisibility +import com.gemwallet.android.application.asset_select.coordinators.ToggleAssetPin +import com.gemwallet.android.application.asset_select.coordinators.UpdateRecentAsset +import com.gemwallet.android.application.session.coordinators.GetSession +import com.gemwallet.android.cases.tokens.SearchTokensCase +import com.gemwallet.android.features.asset_select.viewmodels.BaseAssetSelectViewModel +import com.gemwallet.android.features.asset_select.viewmodels.models.BaseSelectSearch +import com.gemwallet.android.ui.components.list_item.AssetItemUIModel +import com.gemwallet.android.ui.models.navigation.RouteArgument +import com.wallet.core.primitives.AssetTag +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class AssetsResultsViewModel @Inject constructor( + getSession: GetSession, + searchSelectAssets: SearchSelectAssets, + getRecentAssets: GetRecentAssets, + updateRecentAsset: UpdateRecentAsset, + switchAssetVisibility: SwitchAssetVisibility, + toggleAssetPin: ToggleAssetPin, + searchTokensCase: SearchTokensCase, + savedStateHandle: SavedStateHandle, +) : BaseAssetSelectViewModel( + getSession, + getRecentAssets, + updateRecentAsset, + switchAssetVisibility, + toggleAssetPin, + searchTokensCase, + BaseSelectSearch(searchSelectAssets), +) { + + init { + val tag = savedStateHandle.get(RouteArgument.Tag.key) + ?.let { value -> AssetTag.entries.firstOrNull { it.string == value } } + selectedTag.value = tag + queryState.setTextAndPlaceCursorAtEnd(savedStateHandle.get(RouteArgument.Query.key).orEmpty()) + } + + val cappedAssets: StateFlow> = combine(pinned, unpinned) { pinned, unpinned -> + unpinned.take((WalletSearchLimits.RESULTS - pinned.size).coerceAtLeast(0)) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) +} diff --git a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchLimits.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchLimits.kt new file mode 100644 index 0000000000..95b8457ab0 --- /dev/null +++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchLimits.kt @@ -0,0 +1,9 @@ +package com.gemwallet.android.features.assets.viewmodels + +object WalletSearchLimits { + const val ASSETS_INITIAL = 12 + const val ASSETS_TAG = 18 + const val ASSETS_SEARCH = 25 + const val PERPETUALS_PREVIEW = 3 + const val RESULTS = 100 +} diff --git a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchViewModel.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchViewModel.kt new file mode 100644 index 0000000000..ccd0ee2a13 --- /dev/null +++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchViewModel.kt @@ -0,0 +1,146 @@ +package com.gemwallet.android.features.assets.viewmodels + +import androidx.lifecycle.viewModelScope +import com.gemwallet.android.application.asset_select.coordinators.GetRecentAssets +import com.gemwallet.android.application.asset_select.coordinators.SearchSelectAssets +import com.gemwallet.android.application.asset_select.coordinators.SwitchAssetVisibility +import com.gemwallet.android.application.asset_select.coordinators.ToggleAssetPin +import com.gemwallet.android.application.asset_select.coordinators.UpdateRecentAsset +import com.gemwallet.android.application.perpetual.coordinators.GetPerpetuals +import com.gemwallet.android.application.perpetual.coordinators.TogglePerpetualPin +import com.gemwallet.android.application.session.coordinators.GetSession +import com.gemwallet.android.cases.tokens.SearchTokensCase +import com.gemwallet.android.data.repositories.config.UserConfig +import com.gemwallet.android.data.repositories.config.showPerpetuals +import com.gemwallet.android.data.repositories.tokens.WalletSearch +import com.gemwallet.android.domains.perpetual.aggregates.PerpetualDataAggregate +import com.gemwallet.android.ext.toIdentifier +import com.gemwallet.android.features.asset_select.viewmodels.BaseAssetSelectViewModel +import com.gemwallet.android.features.asset_select.viewmodels.models.BaseSelectSearch +import com.gemwallet.android.features.asset_select.viewmodels.models.UIState +import com.gemwallet.android.model.RecentAssetsRequest +import com.gemwallet.android.model.RecentType +import com.gemwallet.android.ui.components.list_item.AssetItemUIModel +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.AssetTag +import com.wallet.core.primitives.PerpetualId +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class WalletSearchViewModel @Inject constructor( + getSession: GetSession, + searchSelectAssets: SearchSelectAssets, + getRecentAssets: GetRecentAssets, + updateRecentAsset: UpdateRecentAsset, + switchAssetVisibility: SwitchAssetVisibility, + toggleAssetPin: ToggleAssetPin, + @WalletSearch searchTokensCase: SearchTokensCase, + getPerpetuals: GetPerpetuals, + userConfig: UserConfig, + private val togglePerpetualPin: TogglePerpetualPin, +) : BaseAssetSelectViewModel( + getSession, + getRecentAssets, + updateRecentAsset, + switchAssetVisibility, + toggleAssetPin, + searchTokensCase, + BaseSelectSearch(searchSelectAssets), +) { + + private val showPerpetuals = userConfig.showPerpetuals(getSession()) + + private val visiblePerpetuals = combine( + getPerpetuals.getPerpetuals(currentQuery.map { it.takeIf(String::isNotEmpty) }), + showPerpetuals, + selectedTag, + ) { items, show, tag -> + if (show && tag == null) items else emptyList() + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val pinnedPerpetuals: StateFlow> = visiblePerpetuals + .map { items -> items.filter { it.isPinned } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + private val perpetuals: StateFlow> = visiblePerpetuals + .map { items -> items.filter { !it.isPinned } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val previewPerpetuals: StateFlow> = perpetuals + .map { items -> items.take(WalletSearchLimits.PERPETUALS_PREVIEW) } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val hasMorePerpetuals: StateFlow = visiblePerpetuals + .map { items -> items.size > WalletSearchLimits.PERPETUALS_PREVIEW } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val perpetualRecentIds: StateFlow> = + getRecentAssets(RecentAssetsRequest(types = listOf(RecentType.Perpetual))) + .map { items -> items.mapTo(HashSet()) { it.asset.id.toIdentifier() } } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) + + val previewAssets: StateFlow> = combine( + unpinned, currentQuery, selectedTag, + ) { items, query, tag -> + items.take(assetsLimit(query, tag)) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val hasMoreAssets: StateFlow = combine( + pinned, unpinned, currentQuery, selectedTag, + ) { pinned, unpinned, query, tag -> + (pinned.size + unpinned.size) > assetsLimit(query, tag) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val state: StateFlow = combine( + uiState, previewPerpetuals, pinnedPerpetuals, + ) { base, preview, pinnedPerps -> + if (preview.isNotEmpty() || pinnedPerps.isNotEmpty()) UIState.Idle else base + } + .stateIn(viewModelScope, SharingStarted.Eagerly, UIState.Idle) + + private fun assetsLimit(query: String, tag: AssetTag?): Int = when { + query.isNotEmpty() -> WalletSearchLimits.ASSETS_SEARCH + tag != null -> WalletSearchLimits.ASSETS_TAG + else -> WalletSearchLimits.ASSETS_INITIAL + } + + init { + viewModelScope.launch { + currentQuery.collect { query -> + if (query.isNotEmpty() && selectedTag.value != null) { + selectedTag.value = null + } + } + } + } + + fun onPinAsset(assetId: AssetId) { + val willPin = (pinned.value + unpinned.value).firstOrNull { it.asset.id == assetId }?.metadata?.isPinned != true + onTogglePin(assetId) + if (willPin) onChangeVisibility(assetId, true) + } + + fun onTogglePerpetualPin(perpetualId: PerpetualId) { + togglePerpetualPin.togglePin(perpetualId) + } + + fun onOpenPerpetual(assetId: AssetId) { + updateRecent(assetId, RecentType.Perpetual) + } +} diff --git a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualsPreviewViewModel.kt b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualsPreviewViewModel.kt index 607ce8b9f8..4ae4528ff3 100644 --- a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualsPreviewViewModel.kt +++ b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualsPreviewViewModel.kt @@ -5,10 +5,9 @@ import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.perpetual.coordinators.GetPerpetualPositions import com.gemwallet.android.application.session.coordinators.GetSession import com.gemwallet.android.data.repositories.config.UserConfig -import com.gemwallet.android.ext.hasPerpetualsSupport +import com.gemwallet.android.data.repositories.config.showPerpetuals import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -19,12 +18,8 @@ class PerpetualsPreviewViewModel @Inject constructor( getPositions: GetPerpetualPositions, ) : ViewModel() { - val showPerpetuals = combine( - getSession(), - userConfig.isPerpetualEnabled(), - ) { session, enabled -> - enabled && (session?.wallet?.hasPerpetualsSupport == true) - }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + val showPerpetuals = userConfig.showPerpetuals(getSession()) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) val positions = getPositions.getPerpetualPositions() .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/navigation/RouteArgument.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/navigation/RouteArgument.kt index 13a6a6aba9..eade77965e 100644 --- a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/navigation/RouteArgument.kt +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/navigation/RouteArgument.kt @@ -10,7 +10,9 @@ enum class RouteArgument(val key: String) { NftAssetId("nftAssetId"), NftCollectionId("nftCollectionId"), Params("params"), + Query("query"), SwapItemType("swapItemType"), + Tag("tag"), ToAssetId("toAssetId"), TransactionId("transactionId"), Type("type"), From 0d6b18d4db16facec6dd8f96ea0936885e7eba01 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:34:35 +0300 Subject: [PATCH 07/11] Render pinned perpetuals and assets as one section Pinned perpetuals and pinned assets share the Pinned header but were positioned independently, so a single pinned perpetual plus a single pinned asset rendered as two separate rounded cards instead of one joined card like iOS. Position both sub-lists against the combined count via itemsPositioned indexOffset/totalCount, and drop the now-unused getListPosition extension. --- .../presents/views/AssetSelectScene.kt | 25 +++++++++++++++---- .../assets/views/WalletSearchScreen.kt | 24 ++++++++---------- .../android/ui/models/ListPosition.kt | 4 +-- .../list_item/property/itemsPositioned.kt | 5 ++-- 4 files changed, 34 insertions(+), 24 deletions(-) diff --git a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetSelectScene.kt b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetSelectScene.kt index 39691023df..e8ec360d5e 100644 --- a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetSelectScene.kt +++ b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/AssetSelectScene.kt @@ -173,7 +173,7 @@ fun AssetSelectScene( onSelectRecent: ((AssetId) -> Unit)? = null, onOpenRecentsSheet: (() -> Unit)? = null, contextActions: AssetContextActions = AssetContextActions.Empty, - pinnedPerpetualsContent: (LazyListScope.() -> Unit)? = null, + pinnedPerpetualRows: List<@Composable (ListPosition) -> Unit> = emptyList(), perpetualsContent: (LazyListScope.() -> Unit)? = null, assetsHeaderRes: Int? = null, onAssetsHeaderClick: (() -> Unit)? = null, @@ -246,10 +246,23 @@ fun AssetSelectScene( } recent(recent, onSelectRecent, onOpenRecentsSheet) assets(popular, AssetsGroupType.Popular, onSelect, support, titleBadge, itemTrailing, longPressedAsset, contextActions) - if (pinned.isNotEmpty() || pinnedPerpetualsContent != null) { + if (pinned.isNotEmpty() || pinnedPerpetualRows.isNotEmpty()) { item { PinnedAssetsHeaderItem(AssetsGroupType.Pined) } - pinnedPerpetualsContent?.invoke(this) - assetRows(pinned, onSelect, support, titleBadge, itemTrailing, longPressedAsset, contextActions) + val pinnedTotal = pinnedPerpetualRows.size + pinned.size + itemsPositioned(pinnedPerpetualRows, totalCount = pinnedTotal) { position, row -> + row(position) + } + assetRows( + pinned, + onSelect, + support, + titleBadge, + itemTrailing, + longPressedAsset, + contextActions, + indexOffset = pinnedPerpetualRows.size, + totalCount = pinnedTotal, + ) } perpetualsContent?.invoke(this) if (assetsHeaderRes != null && unpinned.isNotEmpty()) { @@ -301,8 +314,10 @@ fun LazyListScope.assetRows( itemTrailing: (@Composable (AssetItemUIModel) -> Unit)?, longPressedAsset: MutableState, contextActions: AssetContextActions, + indexOffset: Int = 0, + totalCount: Int = items.size, ) { - itemsPositioned(items) { position, item -> + itemsPositioned(items, indexOffset = indexOffset, totalCount = totalCount) { position, item -> AssetSelectRow( position = position, item = item, diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchScreen.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchScreen.kt index 1f1afcd21d..fed077941b 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchScreen.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/WalletSearchScreen.kt @@ -72,20 +72,16 @@ fun WalletSearchScreen( onAction(WalletSearchAction.OpenPerpetual(assetId)) } - val pinnedPerpetualsContent: (LazyListScope.() -> Unit)? = if (pinnedPerpetuals.isNotEmpty()) { - { - itemsPositioned(pinnedPerpetuals) { position, item -> - PerpetualItem( - item = item, - listPosition = position, - longPressState = longPressedPerpetual, - onTogglePin = viewModel::onTogglePerpetualPin, - onClick = onPerpetualSelect, - ) - } + val pinnedPerpetualRows: List<@Composable (ListPosition) -> Unit> = pinnedPerpetuals.map { item -> + @Composable { position: ListPosition -> + PerpetualItem( + item = item, + listPosition = position, + longPressState = longPressedPerpetual, + onTogglePin = viewModel::onTogglePerpetualPin, + onClick = onPerpetualSelect, + ) } - } else { - null } val perpetualsContent: (LazyListScope.() -> Unit)? = if (previewPerpetuals.isNotEmpty()) { @@ -137,7 +133,7 @@ fun WalletSearchScreen( onTagSelect = viewModel::onTagSelect, itemTrailing = { asset -> getBalanceInfo(asset)() }, contextActions = contextActions, - pinnedPerpetualsContent = pinnedPerpetualsContent, + pinnedPerpetualRows = pinnedPerpetualRows, perpetualsContent = perpetualsContent, assetsHeaderRes = R.string.assets_title, onAssetsHeaderClick = if (hasMoreAssets) { diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/ListPosition.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/ListPosition.kt index e39ce3ea71..b342393fe3 100644 --- a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/ListPosition.kt +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/ListPosition.kt @@ -17,6 +17,4 @@ enum class ListPosition { else -> Middle } } -} - -fun List<*>.getListPosition(index: Int) = ListPosition.getPosition(index, size = size) \ No newline at end of file +} \ No newline at end of file diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/itemsPositioned.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/itemsPositioned.kt index e08c3c4267..dc96565aca 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/itemsPositioned.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/itemsPositioned.kt @@ -5,16 +5,17 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import com.gemwallet.android.ui.models.ListPosition -import com.gemwallet.android.ui.models.getListPosition inline fun LazyListScope.itemsPositioned( items: List, + indexOffset: Int = 0, + totalCount: Int = items.size, noinline key: ((index: Int, item: T) -> Any)? = null, crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null }, crossinline itemContent: @Composable LazyItemScope.(position: ListPosition, item: T) -> Unit, ) { itemsIndexed(items, key, contentType) { index, item -> - val position = items.getListPosition(index) + val position = ListPosition.getPosition(indexOffset + index, totalCount) itemContent(position, item) } } \ No newline at end of file From 2af6a5717b80da372425165c6fec85fb6446c984 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:34:35 +0300 Subject: [PATCH 08/11] Avoid search query restart on redundant priority writes hasPriorities() is a Room flow that re-emits on every search_priority write, and the flatMapLatest keyed on it lacked distinctUntilChanged, so a repeated search for the same query restarted the inner query and reloaded the list. Add distinctUntilChanged across asset, swap, and perpetual search; priority ranking still updates reactively through the JOIN. --- .../repositories/assets/AssetsRepository.kt | 4 ++-- .../perpetual/PerpetualRepositoryImpl.kt | 18 +++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepository.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepository.kt index abfa12952d..798aa6d06c 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepository.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/assets/AssetsRepository.kt @@ -266,7 +266,7 @@ class AssetsRepository @Inject constructor( fun search(query: String, tags: List, byAllWallets: Boolean): Flow> { val query = tags.toPriorityQuery(query) return currentWalletId().flatMapLatest { walletId -> - searchPriorityDao.hasPriorities(query, SearchItemType.Asset.string).map { it > 0 }.flatMapLatest { hasPriority -> + searchPriorityDao.hasPriorities(query, SearchItemType.Asset.string).map { it > 0 }.distinctUntilChanged().flatMapLatest { hasPriority -> when { byAllWallets && hasPriority -> assetsDao.searchByAllWalletsWithPriority(walletId, query) byAllWallets -> assetsDao.searchByAllWallets(walletId, query) @@ -283,7 +283,7 @@ class AssetsRepository @Inject constructor( val walletChains = wallet.accounts.map { it.chain } val includeChains = byChains.filter { walletChains.contains(it) } val includeAssetIds = byAssets.filter { walletChains.contains(it.chain) } - return searchPriorityDao.hasPriorities(query, SearchItemType.Asset.string).map { it > 0 }.flatMapLatest { hasPriority -> + return searchPriorityDao.hasPriorities(query, SearchItemType.Asset.string).map { it > 0 }.distinctUntilChanged().flatMapLatest { hasPriority -> if (hasPriority) { assetsDao.swapSearchWithPriority(wallet.id.id, query, includeChains, includeAssetIds.map { it.toIdentifier() }) } else { diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/perpetual/PerpetualRepositoryImpl.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/perpetual/PerpetualRepositoryImpl.kt index 2f3bef0517..07756f9e2b 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/perpetual/PerpetualRepositoryImpl.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/perpetual/PerpetualRepositoryImpl.kt @@ -24,6 +24,7 @@ import com.wallet.core.primitives.SearchItemType import com.wallet.core.primitives.WalletId import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -46,14 +47,17 @@ class PerpetualRepositoryImpl( if (searchQuery.isEmpty()) { return perpetualDao.getPerpetualsData().toPerpetualData() } - return searchPriorityDao.hasPriorities(searchQuery, SearchItemType.Perpetual.string).flatMapLatest { hasPriority -> - if (hasPriority > 0) { - perpetualDao.searchWithPriority(searchQuery).toPerpetualData() - } else { - perpetualDao.getPerpetualsData().toPerpetualData() - .map { items -> items.filter { it.matches(searchQuery) } } + return searchPriorityDao.hasPriorities(searchQuery, SearchItemType.Perpetual.string) + .map { it > 0 } + .distinctUntilChanged() + .flatMapLatest { hasPriority -> + if (hasPriority) { + perpetualDao.searchWithPriority(searchQuery).toPerpetualData() + } else { + perpetualDao.getPerpetualsData().toPerpetualData() + .map { items -> items.filter { it.matches(searchQuery) } } + } } - } } private fun Flow>.toPerpetualData(): Flow> = From 461a54a201bb73e30fab1ebadf94b7069ab387fc Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:34:35 +0300 Subject: [PATCH 09/11] Debounce token search and make results screen read-only The network token search hung off the 5-input filters flow, firing on session/filter churn rather than only query/tag and without a real debounce, so fast typing launched overlapping searches racing on the no-results state. Drive it off a distinct (query, tag, currency, chains) stream with collectLatest, matching iOS which searches only on user input. Gate the trigger behind remoteSearch and disable it on the AssetsResults drill-down, which re-fetched results already in the store and rewrote search_priority, forcing a reload. It now renders straight from the store like iOS's read-only results view model. --- .../viewmodels/BaseAssetSelectViewModel.kt | 38 +++++++++++++------ .../viewmodels/AssetsResultsViewModel.kt | 2 + 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt index b8a39ee3a8..b4ecbdb0eb 100644 --- a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt +++ b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt @@ -15,7 +15,6 @@ import com.gemwallet.android.cases.tokens.SearchTokensCase import com.gemwallet.android.ext.assetType import com.gemwallet.android.ext.getAccount import com.gemwallet.android.model.RecentType -import com.gemwallet.android.model.Session import com.gemwallet.android.ui.components.list_item.AssetInfoUIModel import com.gemwallet.android.ui.components.list_item.AssetItemUIModel import com.gemwallet.android.features.asset_select.viewmodels.models.SelectAssetFilters @@ -35,12 +34,13 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -73,6 +73,10 @@ open class BaseAssetSelectViewModel( protected val currentQuery = snapshotFlow { queryState.text.toString() } .stateIn(viewModelScope, SharingStarted.Eagerly, "") + private val searchRequests = combine(currentQuery, selectedTag, session) { query, tag, session -> + SearchRequest(query, tag, session?.currency ?: Currency.USD, walletSearchChains(session?.wallet)) + }.distinctUntilChanged() + private val filters = combine( session, currentQuery, @@ -81,7 +85,7 @@ open class BaseAssetSelectViewModel( balanceFilter, ) { session, query, tag, chainFilter, hasBalance -> SelectAssetFilters(session = session, query = query, chainFilter = chainFilter, hasBalance = hasBalance, tag = tag) - }.onEach { request(it.query, it.tag, it.session) } + } .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val assets = combine( @@ -196,15 +200,16 @@ open class BaseAssetSelectViewModel( return session.value?.wallet?.getAccount(assetId) } - private fun request(query: String, tags: AssetTag?, session: Session?) = viewModelScope.launch(Dispatchers.IO) { - delay(SEARCH_DEBOUNCE_MS) - val ok = searchTokensCase.search( - query = query, - currency = session?.currency ?: Currency.USD, - chains = walletSearchChains(session?.wallet), - tags = tags?.let { listOf(it) } ?: emptyList(), - ) - noResultsQuery.value = if (ok) null else query + init { + if (remoteSearch) { + viewModelScope.launch(Dispatchers.IO) { + searchRequests.collectLatest { (query, tag, currency, chains) -> + delay(SEARCH_DEBOUNCE_MS) + val ok = searchTokensCase.search(query, currency, chains, tag?.let { listOf(it) }.orEmpty()) + noResultsQuery.value = if (ok) null else query + } + } + } } private fun walletSearchChains(wallet: Wallet?): List = when (wallet?.type) { @@ -219,8 +224,17 @@ open class BaseAssetSelectViewModel( open val showRecents: Boolean get() = true + protected open val remoteSearch: Boolean get() = true + open fun assetFilters(): Set = emptySet() + private data class SearchRequest( + val query: String, + val tag: AssetTag?, + val currency: Currency, + val chains: List, + ) + private companion object { private const val SEARCH_DEBOUNCE_MS = 250L } diff --git a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt index c5563ab090..ef07729a03 100644 --- a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt +++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt @@ -44,6 +44,8 @@ class AssetsResultsViewModel @Inject constructor( BaseSelectSearch(searchSelectAssets), ) { + override val remoteSearch: Boolean get() = false + init { val tag = savedStateHandle.get(RouteArgument.Tag.key) ?.let { value -> AssetTag.entries.firstOrNull { it.string == value } } From d3f72c14a714e25a3268a5ab8095c6bdb3459a18 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:47:39 +0300 Subject: [PATCH 10/11] Propagate search cancellation and make remote search a constructor flag Catch blocks on the token search path rethrow CancellationException so a search cancelled by a newer query under collectLatest stops instead of completing as a failure and writing a stale no-results marker. Move the remoteSearch toggle from an open property read in the base class init, a virtual call during construction, to a constructor parameter. --- .../data/repositories/tokens/TokensRepository.kt | 3 +++ .../data/repositories/tokens/WalletSearchTokens.kt | 8 +++++++- .../repositories/tokens/WalletSearchTokensTest.kt | 13 +++++++++++++ .../viewmodels/BaseAssetSelectViewModel.kt | 3 +-- .../assets/viewmodels/AssetsResultsViewModel.kt | 3 +-- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepository.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepository.kt index fa0aa51af0..ecd3a49ed2 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepository.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/TokensRepository.kt @@ -19,6 +19,7 @@ import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.AssetTag import com.wallet.core.primitives.Chain import com.wallet.core.primitives.Currency +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withContext @@ -41,6 +42,8 @@ class TokensRepository ( chains = chains, tags = tags, ) + } catch (err: CancellationException) { + throw err } catch (_: Throwable) { return@withContext false } diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokens.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokens.kt index 55be637ee3..6bff5faaaa 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokens.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokens.kt @@ -10,6 +10,7 @@ import com.wallet.core.primitives.Chain import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PerpetualData import com.wallet.core.primitives.PerpetualMetadata +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -26,6 +27,8 @@ class WalletSearchTokens( } val response = try { gemSearch.search(query = query, chains = chains, tags = tags) + } catch (err: CancellationException) { + throw err } catch (_: Throwable) { return@withContext false } @@ -33,11 +36,14 @@ class WalletSearchTokens( val hasAssets = tokensRepository.storeAssets(query, response.assets, currency, priorityQuery) val perpetuals = if (tags.isEmpty()) response.perpetuals else emptyList() if (perpetuals.isNotEmpty()) { - runCatching { + try { perpetualRepository.putPerpetuals( perpetuals.map { PerpetualData(perpetual = it.perpetual, asset = it.asset, metadata = PerpetualMetadata(isPinned = false)) } ) searchPriorityDao.put(perpetuals.toSearchPriority(priorityQuery)) + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { } } hasAssets || perpetuals.isNotEmpty() diff --git a/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokensTest.kt b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokensTest.kt index 3b0847a845..784bd284b8 100644 --- a/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokensTest.kt +++ b/android/data/repositories/src/test/kotlin/com/gemwallet/android/data/repositories/tokens/WalletSearchTokensTest.kt @@ -14,6 +14,7 @@ import com.wallet.core.primitives.SearchResponse import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import org.junit.Assert.assertTrue import org.junit.Test @@ -68,4 +69,16 @@ class WalletSearchTokensTest { coVerify { perpetualRepository.putPerpetuals(any()) } coVerify { searchPriorityDao.put(match { priorities -> priorities.any { it.type == "perpetual" } }) } } + + @Test + fun search_rethrowsCancellationWithoutStoring() = runTest { + coEvery { gemSearch.search(any(), any(), any()) } throws CancellationException("cancelled") + + val result = runCatching { + subject.search(query = "btc", currency = Currency.USD, chains = emptyList(), tags = emptyList()) + } + + assertTrue(result.exceptionOrNull() is CancellationException) + coVerify(exactly = 0) { tokensRepository.storeAssets(any(), any(), any(), any()) } + } } diff --git a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt index c2be6f0988..4249fbfe21 100644 --- a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt +++ b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt @@ -54,6 +54,7 @@ open class BaseAssetSelectViewModel( private val toggleAssetPin: ToggleAssetPin, private val searchTokensCase: SearchTokensCase, val search: SelectSearch, + private val remoteSearch: Boolean = true, ) : ViewModel() { val queryState = TextFieldState() @@ -226,8 +227,6 @@ open class BaseAssetSelectViewModel( open val recentTypes: List get() = RecentType.entries - protected open val remoteSearch: Boolean get() = true - open fun assetFilters(): Set = emptySet() private data class SearchRequest( diff --git a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt index ef07729a03..ac7a3564f3 100644 --- a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt +++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsResultsViewModel.kt @@ -42,10 +42,9 @@ class AssetsResultsViewModel @Inject constructor( toggleAssetPin, searchTokensCase, BaseSelectSearch(searchSelectAssets), + remoteSearch = false, ) { - override val remoteSearch: Boolean get() = false - init { val tag = savedStateHandle.get(RouteArgument.Tag.key) ?.let { value -> AssetTag.entries.firstOrNull { it.string == value } } From b72f71ec57567f065862a84fcf756248808adc95 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:43:10 +0300 Subject: [PATCH 11/11] Clear selected tag on query input across all asset pickers Only wallet search reset the tag when the user typed; Send, Buy, Swap, and price alert pickers kept it applied invisibly (the tag bar hides while typing), filtered results by query::tag so the local LIKE fallback matched nothing, and restored the stale tag when the query cleared. iOS resets the tag on search focus in every picker via SelectAssetViewModel.onChangeFocus. Move the rule from WalletSearchViewModel into the shared base. --- .../viewmodels/BaseAssetSelectViewModel.kt | 7 +++++++ .../assets/viewmodels/WalletSearchViewModel.kt | 11 ----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt index 4249fbfe21..b3ee75a6e4 100644 --- a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt +++ b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt @@ -202,6 +202,13 @@ open class BaseAssetSelectViewModel( } init { + viewModelScope.launch { + currentQuery.collect { query -> + if (query.isNotEmpty() && selectedTag.value != null) { + selectedTag.value = null + } + } + } if (remoteSearch) { viewModelScope.launch(Dispatchers.IO) { searchRequests.collectLatest { (query, tag, currency, chains) -> diff --git a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchViewModel.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchViewModel.kt index ccd0ee2a13..960b664e76 100644 --- a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchViewModel.kt +++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/WalletSearchViewModel.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -120,16 +119,6 @@ class WalletSearchViewModel @Inject constructor( else -> WalletSearchLimits.ASSETS_INITIAL } - init { - viewModelScope.launch { - currentQuery.collect { query -> - if (query.isNotEmpty() && selectedTag.value != null) { - selectedTag.value = null - } - } - } - } - fun onPinAsset(assetId: AssetId) { val willPin = (pinned.value + unpinned.value).firstOrNull { it.asset.id == assetId }?.metadata?.isPinned != true onTogglePin(assetId)