Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 15:02:38 +03:00
commit e0033cef82
1726 changed files with 83681 additions and 14949 deletions

View file

@ -3,7 +3,6 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
@ -12,19 +11,23 @@ android {
}
dependencies {
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Other libraries */
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Core modules */
// region Other libraries
implementation(deps.amplitude.experiment)
// endregion
// region Core modules
implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.models)
/** Amplitude experiment */
implementation(deps.amplitude.experiment)
// endregion
}

View file

@ -3,28 +3,39 @@ plugins {
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
dependencies {
/** DI */
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
/** Analytics - Models */
api(projects.core.analytics.models)
/** Domain */
implementation(projects.domain.analytics)
implementation(projects.domain.models)
/** Other */
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Core shouldn't depend on core, but in case with utils and logging its necessary */
// region Tangem
implementation(tangemDeps.card.core) // for calculating user id hash
// endregion
// region Core modules
api(projects.core.analytics.models)
// Core shouldn't depend on core, but with utils and logging it's necessary.
implementation(projects.core.utils)
// endregion
/** For calculating user id hash */
implementation(tangemDeps.card.core)
// region Domain
api(projects.domain.analytics)
// endregion
/** Tests */
testImplementation(projects.test.core)
// region Domain models
api(projects.domain.models)
// endregion
// region Tests
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -86,8 +86,6 @@ sealed class MainScreenAnalyticsEvent(
class BuyScreenOpened : MainScreenAnalyticsEvent(event = "Buy Screen Opened")
class SwapScreenOpened : MainScreenAnalyticsEvent(event = "Swap Screen Opened")
class SellScreenOpened : MainScreenAnalyticsEvent(event = "Sell Screen Opened")
data class BuyTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
@ -100,21 +98,6 @@ sealed class MainScreenAnalyticsEvent(
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class SwapTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Swap Token Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class ReceiveTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Receive Token Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class RemoveTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Remove Button Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class ButtonClose(val source: AnalyticsParam.ScreensSources) : MainScreenAnalyticsEvent(
event = "Button - Close",
params = mapOf(AnalyticsParam.SOURCE to source.value),

View file

@ -1,11 +1,7 @@
package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEARCHED
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources
/**
[REDACTED_AUTHOR]
@ -15,19 +11,6 @@ sealed class SwapAnalyticsEvent(
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent("Swap", event, params) {
data class TokenSelected(
val token: String,
val source: ScreensSources,
val isSearched: Boolean,
) : SwapAnalyticsEvent(
event = "Token Selected",
params = mapOf(
TOKEN_PARAM to token,
SOURCE to source.value,
SEARCHED to if (isSearched) "True" else "False",
),
)
class FilterProvider(filterType: String) : SwapAnalyticsEvent(
event = "Filter Provider",
params = mapOf(TYPE to filterType),

View file

@ -0,0 +1,70 @@
# core/config-toggles
Feature toggles (and the related excluded-blockchains toggles). Toggles gate
features by app version; the JSON config is the source of truth and the
`FeatureToggles` enum is generated from it at build time.
## How it works
- **Config:** `src/main/assets/configs/feature_toggles_config.json` — a JSON array
of `{ "name": <STRING>, "version": <STRING> }` (`ConfigToggle`).
- The **convention plugin** generates the `FeatureToggles` enum (one entry per
`name`) at build time. Reference it as `FeatureToggles.<NAME>`.
- **Entry point:** `FeatureTogglesManager.isFeatureEnabled(FeatureToggles.X)`.
- `ProdFeatureTogglesManager` (release): a toggle is enabled when the app
version `>=` its `version`.
- `DevFeatureTogglesManager` (tester builds, `BuildConfig.TESTER_MENU_ENABLED`):
runtime-toggleable via the Tester Menu.
- **`version` semantics:**
- `"undefined"` (`DISABLED_FEATURE_TOGGLE_VERSION`) → OFF in prod; can only be
flipped ON via the Tester Menu / dev builds. Use this while a feature is in
development.
- `"X.Y"` (e.g. `5.40`) → ON in prod from that app version onward
(`currentVersion >= localVersion`, see `VersionAvailabilityContract`).
## Naming convention (ENFORCED by a test)
- A toggle `name` MUST match `^(AND|TWI)_\d+(?:_[A-Z0-9]+)+$` — start with the
Jira ticket id (`AND_<id>` for Android tickets, `TWI_<id>` for idea tickets),
then an `UPPER_SNAKE_CASE` suffix. Example: `AND_15901_STORIES_CONTAINER_ENABLED`.
- Enforced by `FeatureTogglesNamingConventionTest`. Legacy toggles that predate
the rule are whitelisted in its `EXCLUDED_TOGGLES_LIST` — do **not** add new
names there without an explicit reason.
- The Kotlin interface property stays human-readable **without** the ticket id:
`isStoriesContainerEnabled`.
## Per-feature toggles & how to add one
Each feature owns its toggles — feature code reads them through its own
interface, never `FeatureTogglesManager` directly:
- `api/`: `XxxFeatureToggles` interface — `val isYyyEnabled: Boolean`.
- `impl/`: `DefaultXxxFeatureToggles(featureTogglesManager)` exposes each toggle as
a **getter-backed property**, not a stored value — so it is re-evaluated on every
read (required for runtime toggling via the Tester Menu):
```kotlin
override val isYyyEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_<id>_YYY)
```
Never `val isYyyEnabled = featureTogglesManager.isFeatureEnabled(...)` (evaluated
once at construction).
- DI: a `@Provides @Singleton` in the feature's Hilt module returning the interface.
To add a toggle:
1. Add `{ "name": "AND_<id>_FOO_ENABLED", "version": "undefined" }` to the config
JSON (the enum is regenerated at build).
2. Add `val isFooEnabled` to the feature's `XxxFeatureToggles` and map it in
`DefaultXxxFeatureToggles` (create the interface/impl/DI provider if the
feature has none yet).
3. Gate code on `xxxFeatureToggles.isFooEnabled`.
## Removing (cleanup)
When a toggle ships at 100%, set its `version` to the release and run the
`cleanup-feature-toggles` skill — it removes the JSON entry, the interface/impl
members, inlines `true`, and drops dead branches. Mark code that must be deleted
together with a toggle using `@RemoveWithToggle("AND_<id>_FOO_ENABLED")`
(`com.tangem.utils.annotations.RemoveWithToggle`); the cleanup skill picks it up.

View file

@ -63,21 +63,32 @@ tasks.withType<Detekt>().configureEach {
exclude { it.file.absolutePath.contains("/build/generated/") }
}
dependencies {
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Local storages */
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region AndroidX
implementation(deps.androidx.annotation)
implementation(deps.androidx.datastore)
// endregion
/** Other libraries */
// region Other libraries
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
// endregion
/** Core modules */
// region Core modules
implementation(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Tests
testImplementation(projects.test.core)
// endregion
}

View file

@ -1,4 +1,12 @@
[
{
"name": "AND_15901_STORIES_CONTAINER_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1322_FORCE_UPDATE_ENABLED",
"version": "undefined"
},
{
"name": "NEW_CARD_SCANNING_ENABLED",
"version": "undefined"
@ -8,21 +16,13 @@
"version": "undefined"
},
{
"name": "STAKING_ETH_ENABLED",
"version": "5.39"
},
{
"name": "USEDESK_ENABLED",
"name": "TWI_485_USEDESK_ENABLED",
"version": "undefined"
},
{
"name": "APP_REDESIGN_ENABLED",
"version": "6.0"
},
{
"name": "GASLESS_APPROVAL_ENABLED",
"version": "5.37"
},
{
"name": "DYNAMIC_ADDRESSES_ENABLED",
"version": "5.39"
@ -47,10 +47,6 @@
"name": "HEDERA_ERC20_ENABLED",
"version": "5.37"
},
{
"name": "ADD_AND_MANAGE_TOKENS_ENABLED",
"version": "5.38"
},
{
"name": "WALLET_CONNECT_BITCOIN_ENABLED",
"version": "undefined"
@ -71,10 +67,6 @@
"name": "AND_15120_SWAP_INTEGRATED_APPROVE",
"version": "6.0"
},
{
"name": "SWAP_AB_ENABLED",
"version": "5.39"
},
{
"name": "TWI_1403_PUSH_NOTIFICATION_SETTINGS_ENABLED",
"version": "undefined"
@ -91,10 +83,6 @@
"name": "TWI_1377_MANAGE_FUNDS",
"version": "6.0"
},
{
"name": "AND_15009_SWAP_PROVIDER_FILTER_ENABLED",
"version": "5.39"
},
{
"name": "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING",
"version": "5.39"
@ -103,18 +91,6 @@
"name": "AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED",
"version": "5.39"
},
{
"name": "AND_15103_SWAP_RATE_EXPERIENCE_ENABLED",
"version": "5.39"
},
{
"name": "AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED",
"version": "5.39"
},
{
"name": "AND_15154_YIELD_PROMO_ENABLED",
"version": "5.39.2"
},
{
"name": "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED",
"version": "5.39"
@ -143,10 +119,18 @@
"name": "AND_15741_VISA_PAY_REMOVE_ACCOUNT",
"version": "undefined"
},
{
"name": "AND_16041_VISA_TIERS_PLUS_PLAN",
"version": "undefined"
},
{
"name": "TWI_83_ADDRESS_BOOK_ENABLED",
"version": "undefined"
},
{
"name": "AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED",
"version": "undefined"
},
{
"name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED",
"version": "6.0"
@ -170,5 +154,29 @@
{
"name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED",
"version": "6.0"
},
{
"name": "TWI_1522_MARKETING_BANNERS_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1469_FOR_YOU_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1367_HIGH_FEE_WARNING_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1638_VA_MVP0_ENABLED",
"version": "6.1"
},
{
"name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED",
"version": "undefined"
},
{
"name": "AND_16080_TRON_DEX_SWAP_ENABLED",
"version": "undefined"
}
]

View file

@ -39,18 +39,13 @@ internal class FeatureTogglesNamingConventionTest {
/** Toggles created before the AND_/TWI_ naming convention. Do NOT add new entries. */
val EXCLUDED_TOGGLES_LIST = setOf(
"ADDRESS_SYNC_ENABLED",
"ADD_AND_MANAGE_TOKENS_ENABLED",
"APP_REDESIGN_ENABLED",
"ASSETS_DISCOVERY_ENABLED",
"DYNAMIC_ADDRESSES_ENABLED",
"GASLESS_APPROVAL_ENABLED",
"HEDERA_ERC20_ENABLED",
"NEW_CARD_SCANNING_ENABLED",
"SOLANA_SCALED_UI_AMOUNT_ENABLED",
"SOLANA_TX_HISTORY_ENABLED",
"STAKING_ETH_ENABLED",
"SWAP_AB_ENABLED",
"USEDESK_ENABLED",
"VIRTUAL_ACCOUNTS_ENABLED",
"VISA_ONBOARDING_ENABLED",
"WALLET_CONNECT_BITCOIN_ENABLED",

View file

@ -60,70 +60,80 @@ androidComponents {
}
dependencies {
/** Project */
implementation(projects.core.analytics)
implementation(projects.core.utils)
implementation(projects.core.res)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.core)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.visa.models)
/** Tangem libraries */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Coroutines */
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.datetime)
api(deps.kotlin.serialization)
// endregion
/** Logging */
// region AndroidX
implementation(deps.androidx.core)
api(deps.androidx.datastore)
// endregion
/** Network */
// region Network
api(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.moshi.adapters.ext)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
api(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.okio)
api(deps.retrofit)
implementation(deps.retrofit.moshi)
ksp(deps.moshi.kotlin.codegen)
api(deps.retrofit.moshi)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
/** Time */
implementation(deps.jodatime)
// region Room
api(deps.room.runtime)
api(deps.room.ktx)
ksp(deps.room.compiler)
// endregion
/** Security */
implementation(deps.spongecastle.core)
// region Other libraries
api(deps.jodatime)
runtimeOnly(deps.spongecastle.core)
// endregion
/** Chucker */
// region Chucker
debugImplementation(deps.chucker)
mockedImplementation(deps.chucker)
externalImplementation(deps.chuckerStub)
internalImplementation(deps.chuckerStub)
releaseImplementation(deps.chuckerStub)
// endregion
/** Local storages */
api(deps.androidx.datastore)
implementation(deps.room.runtime)
implementation(deps.room.ktx)
ksp(deps.room.compiler)
// region Tangem
api(tangemDeps.blockchain)
api(tangemDeps.card.core)
// endregion
// region Core modules
api(projects.core.analytics)
implementation(projects.core.analytics.models)
api(projects.core.utils)
implementation(projects.core.res)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.nft.models)
api(projects.domain.onramp.models)
api(projects.domain.staking.models)
api(projects.domain.txhistory.models)
api(projects.domain.visa.models)
api(projects.domain.walletConnect.models)
api(projects.domain.wallets.models)
api(projects.domain.yieldSupply.models)
// endregion
// region Tests
testImplementation(projects.test.core)
// endregion
}

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "442ac578743a8b624777711cf49c77e2",
"identityHash": "1ddc01e929ea21940e7f1e9e35810691",
"entities": [
{
"tableName": "express_provider",
@ -81,7 +81,7 @@
},
{
"tableName": "express_exchange",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT NOT NULL, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"fields": [
{
"fieldPath": "txId",
@ -89,12 +89,6 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ownerAddress",
"columnName": "owner_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerId",
"columnName": "provider_id",
@ -104,7 +98,8 @@
{
"fieldPath": "fromAddress",
"columnName": "from_address",
"affinity": "TEXT"
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payinAddress",
@ -264,33 +259,34 @@
},
"indices": [
{
"name": "index_express_exchange_owner_address_from_network_from_contract_address_created_at",
"name": "index_express_exchange_from_address_from_network_from_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"from_address",
"from_network",
"from_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_from_contract_address_created_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `from_contract_address`, `created_at`)"
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_from_address_from_network_from_contract_address_created_at` ON `${TABLE_NAME}` (`from_address`, `from_network`, `from_contract_address`, `created_at`)"
},
{
"name": "index_express_exchange_to_network_to_contract_address_created_at",
"name": "index_express_exchange_payout_address_to_network_to_contract_address_created_at",
"unique": false,
"columnNames": [
"payout_address",
"to_network",
"to_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_to_network_to_contract_address_created_at` ON `${TABLE_NAME}` (`to_network`, `to_contract_address`, `created_at`)"
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_payout_address_to_network_to_contract_address_created_at` ON `${TABLE_NAME}` (`payout_address`, `to_network`, `to_contract_address`, `created_at`)"
}
]
},
{
"tableName": "express_onramp",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `payout_address` TEXT NOT NULL, `status` TEXT NOT NULL, `fail_reason` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `payout_hash` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `from_precision` INTEGER NOT NULL, `payment_method` TEXT NOT NULL, `country_code` TEXT NOT NULL, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `payout_address` TEXT NOT NULL, `status` TEXT NOT NULL, `fail_reason` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `payout_hash` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `from_precision` INTEGER NOT NULL, `payment_method` TEXT NOT NULL, `country_code` TEXT NOT NULL, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"fields": [
{
"fieldPath": "txId",
@ -298,12 +294,6 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ownerAddress",
"columnName": "owner_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerId",
"columnName": "provider_id",
@ -421,16 +411,16 @@
},
"indices": [
{
"name": "index_express_onramp_owner_address_to_network_to_contract_address_created_at",
"name": "index_express_onramp_payout_address_to_network_to_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"payout_address",
"to_network",
"to_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_to_contract_address_created_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `to_contract_address`, `created_at`)"
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_payout_address_to_network_to_contract_address_created_at` ON `${TABLE_NAME}` (`payout_address`, `to_network`, `to_contract_address`, `created_at`)"
}
]
},
@ -474,11 +464,194 @@
"address"
]
}
},
{
"tableName": "onramp_country",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, `name` TEXT NOT NULL, `image` TEXT NOT NULL, `alpha3` TEXT NOT NULL, `continent` TEXT NOT NULL, `onramp_available` INTEGER NOT NULL, `currency_name` TEXT NOT NULL, `currency_code` TEXT NOT NULL, `currency_image` TEXT, `currency_precision` INTEGER NOT NULL, `currency_unit` TEXT NOT NULL, PRIMARY KEY(`code`))",
"fields": [
{
"fieldPath": "code",
"columnName": "code",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "alpha3",
"columnName": "alpha3",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "continent",
"columnName": "continent",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnrampAvailable",
"columnName": "onramp_available",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultCurrency.name",
"columnName": "currency_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultCurrency.code",
"columnName": "currency_code",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultCurrency.image",
"columnName": "currency_image",
"affinity": "TEXT"
},
{
"fieldPath": "defaultCurrency.precision",
"columnName": "currency_precision",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultCurrency.unit",
"columnName": "currency_unit",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"code"
]
}
},
{
"tableName": "token_info",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`network_id` TEXT NOT NULL, `contract_address` TEXT NOT NULL, `coin_id` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`network_id`, `contract_address`))",
"fields": [
{
"fieldPath": "networkId",
"columnName": "network_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contractAddress",
"columnName": "contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coinId",
"columnName": "coin_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": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"network_id",
"contract_address"
]
}
},
{
"tableName": "history_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `address` TEXT NOT NULL, `sort_time_millis` INTEGER NOT NULL, PRIMARY KEY(`type`, `entity_id`, `address`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortTimeMillis",
"columnName": "sort_time_millis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"type",
"entity_id",
"address"
]
},
"indices": [
{
"name": "index_history_index_address_sort_time_millis_entity_id",
"unique": false,
"columnNames": [
"address",
"sort_time_millis",
"entity_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_history_index_address_sort_time_millis_entity_id` ON `${TABLE_NAME}` (`address`, `sort_time_millis`, `entity_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, '442ac578743a8b624777711cf49c77e2')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1ddc01e929ea21940e7f1e9e35810691')"
]
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.addressbook
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
import com.tangem.datasource.api.common.response.ApiResponse
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
interface AddressBookApi {
@POST("v1/address-books/sync")
suspend fun syncAddressBooks(@Body body: SyncAddressBooksRequest): ApiResponse<SyncAddressBooksResponse>
@PUT("v1/address-books/{walletId}")
suspend fun updateAddressBook(
@Path("walletId") walletId: String,
@Header("If-Match") eTag: String?,
@Body body: UpdateAddressBookRequest,
): ApiResponse<UpdateAddressBookResponse>
}

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for `POST /address-books/sync`.
*
* Each [Wallet.etag] is optional: when it matches the backend's etag, that wallet is omitted from the
* response and the local copy is kept.
*/
@JsonClass(generateAdapter = true)
data class SyncAddressBooksRequest(
@Json(name = "wallets") val wallets: List<Wallet>,
) {
@JsonClass(generateAdapter = true)
data class Wallet(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String? = null,
)
}

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response body for `POST /address-books/sync`.
*
* [items] contains only the wallets whose backend etag differs from the one sent in the request; wallets
* with a matching etag are omitted and their local copy must be kept.
*/
@JsonClass(generateAdapter = true)
data class SyncAddressBooksResponse(
@Json(name = "items") val items: List<Item>,
) {
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String,
@Json(name = "version") val version: String,
@Json(name = "updatedAt") val updatedAt: String,
@Json(name = "nonce") val nonce: String,
@Json(name = "ciphertext") val ciphertext: String,
@Json(name = "authTag") val authTag: String,
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for `PUT /address-books/{walletId}`. */
@JsonClass(generateAdapter = true)
data class UpdateAddressBookRequest(
@Json(name = "version") val version: String,
@Json(name = "nonce") val nonce: String,
@Json(name = "ciphertext") val ciphertext: String,
@Json(name = "authTag") val authTag: String,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Response body for `PUT /address-books/{walletId}`. */
@JsonClass(generateAdapter = true)
data class UpdateAddressBookResponse(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String,
@Json(name = "updatedAt") val updatedAt: String,
)

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.auth.models.request.AuthApiRequest
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterApiRequest
import com.tangem.datasource.api.auth.models.request.WalletRegistrationRequest
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.datasource.api.common.response.ApiResponse
@ -20,7 +21,7 @@ interface AuthApi {
*
* Generates a nonce bound to the device public key for the device registration flow.
*/
@POST("api/v1/auth/nonce/device")
@POST("api/authentication/v1/mobile/nonce/device")
suspend fun requestDeviceNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
@ -29,16 +30,15 @@ interface AuthApi {
* Registers a new device using its hardware-backed public key and issues the initial
* session token pair. Called once per app install.
*/
@POST("api/v1/auth/register")
@RequiresDpopProof
suspend fun register(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
@POST("api/authentication/v1/mobile/register")
suspend fun registerDevice(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
/**
* Request authentication nonce.
*
* Generates a nonce bound to the device public key for the authentication flow.
*/
@POST("api/v1/auth/nonce/auth")
@POST("api/authentication/v1/mobile/nonce/auth")
suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
@ -48,7 +48,7 @@ interface AuthApi {
* JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after
* registration uses this endpoint.
*/
@POST("api/v1/auth/authenticate")
@POST("api/authentication/v1/mobile/authenticate")
suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse>
/**
@ -58,7 +58,26 @@ interface AuthApi {
* with family-based reuse detection replaying a consumed token revokes the entire token
* family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`).
*/
@POST("api/v1/auth/refresh")
@POST("api/authentication/v1/mobile/token/refresh")
@RequiresDpopProof
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
/**
* Request wallet registration nonce.
*
* Generates a nonce bound to the device public key for the wallet registration flow.
*/
@POST("api/authentication/v1/mobile/nonce/wallet")
suspend fun requestWalletNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Register a wallet.
*
* Binds a new wallet to an already-registered device. When a card signature is provided the
* wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet.
* Returns refreshed session tokens reflecting the updated wallet list.
*/
@POST("api/authentication/v1/mobile/wallet/register")
@RequiresDpopProof
suspend fun registerWallet(@Body request: WalletRegistrationRequest): ApiResponse<TokenApiResponse>
}

View file

@ -10,17 +10,17 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@Json(name = "deviceModel") val deviceModel: String?,
@Json(name = "deviceModel") val deviceModel: String,
/** Operating system (`android` / `ios`). */
@Json(name = "os") val os: String,
/** OS version string (e.g. `17.4.1`). */
@Json(name = "osVersion") val osVersion: String?,
@Json(name = "osVersion") val osVersion: String,
/** Application version (e.g. `5.8.0`). */
@Json(name = "appVersion") val appVersion: String?,
@Json(name = "appVersion") val appVersion: String,
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
@Json(name = "userAgent") val userAgent: String?,
@Json(name = "userAgent") val userAgent: String,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
@Json(name = "locale") val locale: String,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
@Json(name = "timezone") val timezone: String,
)

View file

@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass
/**
* Registration request registers a new device and establishes initial trust.
*
* Posted to `POST /api/v1/auth/register`; on success the server returns
* On success the server returns
* [com.tangem.datasource.api.auth.models.response.TokenApiResponse] (the initial session token pair).
*/
@JsonClass(generateAdapter = true)
@ -22,7 +22,7 @@ data class RegisterApiRequest(
data class RegisterPayload(
/** Base64-encoded EC public key of the device. */
@Json(name = "devicePublicKey") val devicePublicKey: String,
/** Deciphered nonce value from the `/api/v1/auth/nonce/device` endpoint. */
/** Deciphered nonce value from the device nonce endpoint. */
@Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). Optional; backend accepts `null`. */
@Json(name = "attestationToken") val attestationToken: String?,

View file

@ -0,0 +1,46 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Wallet registration request binds a new wallet to an already-registered device.
*
* When [cardSignature] (and the accompanying [cardSignatureSalt] / [walletStatus]) is provided the
* wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet.
* Mirrors the `WalletRegistrationRequest` schema in the backend OpenAPI contract.
*/
@JsonClass(generateAdapter = true)
data class WalletRegistrationRequest(
/** Deciphered nonce value from the wallet nonce endpoint. */
@Json(name = "nonce") val nonce: String,
/**
* Wallet identifier Base64-encoded
* `HMAC-SHA256(key = SHA-256(walletPublicKey), data = "UserWalletID")`.
*/
@Json(name = "walletId") val walletId: String,
/**
* Base64-encoded secp256k1 RSV signature (65 bytes) over `sha256(nonce || walletSignatureSalt)`.
* The server recovers `walletPublicKey` from this signature.
*/
@Json(name = "walletSignature") val walletSignature: String,
/** Base64-encoded salt used in the wallet signature hash. */
@Json(name = "walletSignatureSalt") val walletSignatureSalt: String,
/**
* Base64-encoded secp256k1 RSV signature (65 bytes) over
* `sha256(walletPublicKey || nonce || cardSignatureSalt || walletStatus)`. Required for
* cold-wallet registration; `null` for mobile (hot) wallets.
*/
@Json(name = "cardSignature") val cardSignature: String?,
/** Base64-encoded salt used in the card signature hash. Required for cold-wallet registration. */
@Json(name = "cardSignatureSalt") val cardSignatureSalt: String?,
/**
* Base64-encoded single byte describing wallet provenance on the card
* (`0x82` = generated on card, `0xC2` = SEED imported). Required for cold-wallet registration.
*/
@Json(name = "walletStatus") val walletStatus: String?,
/** Platform attestation token (Play Integrity / App Attest). */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
)

View file

@ -43,6 +43,6 @@ internal class Auth : ApiConfig() {
private companion object {
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
private const val PROD_BASE_URL = "https://authentication.tangem.org/"
private const val PROD_BASE_URL = "https://api.tangem.org/"
}
}

View file

@ -55,6 +55,7 @@ internal sealed class TangemPay(
"version" to ProviderSuspend { appInfoProvider.appVersion },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) },
"X-Device-Scale" to ProviderSuspend { appInfoProvider.deviceScale.toString() },
)
private fun getBffStaticToken(apiEnvironment: ApiEnvironment): String {

View file

@ -79,11 +79,8 @@ data class ExchangeItemResponse(
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
// todo txHistory uncomment
/*
@Json(name = "updatedAt")
val updatedAt: String,
*/
/** Pay-in expiration timestamp in ISO-8601 format */
@Json(name = "payTill")

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.api.gasless
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
import retrofit2.http.Body
import retrofit2.http.POST
interface GaslessTxServiceApiV2 {
@POST("api/v2/transaction/sign")
suspend fun signGaslessTransaction(
@Body transaction: GaslessTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
@POST("api/v2/transaction/batch-sign")
suspend fun signGaslessBatchTransaction(
@Body transaction: GaslessBatchTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
}

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for gasless batch transaction submission (v2 `POST /api/v2/transaction/batch-sign`).
* Represents a batch of transactions with fee delegation metadata.
*
* The top-level payload field is `gaslessTransaction` (shared shape with single sign see
* gasless-service `BatchSignRequestDto`), carrying `transactions[]`, `fee`, `nonce`.
*/
@JsonClass(generateAdapter = true)
data class GaslessBatchTransactionRequest(
@Json(name = "gaslessTransaction")
val gaslessTransaction: GaslessBatchTransactionDataDTO,
@Json(name = "signature")
val signature: String,
@Json(name = "userAddress")
val userAddress: String,
@Json(name = "chainId")
val chainId: Int,
@Json(name = "eip7702auth")
val eip7702Auth: Eip7702AuthorizationDTO? = null,
)
@JsonClass(generateAdapter = true)
data class GaslessBatchTransactionDataDTO(
@Json(name = "transactions")
val transactions: List<TransactionData>,
@Json(name = "fee")
val fee: FeeData,
@Json(name = "nonce")
val nonce: String,
)

View file

@ -45,6 +45,9 @@ data class TransactionData(
@Json(name = "value")
val value: String,
@Json(name = "gasLimit")
val gasLimit: String? = null,
@Json(name = "data")
val data: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.marketing.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Cached campaigns response plus its ETag, persisted per [CampaignDto.type] for revalidation. */
@JsonClass(generateAdapter = true)
data class MarketingCampaignsCacheEntry(
@Json(name = "eTag") val eTag: String?,
@Json(name = "response") val response: MarketingCampaignsResponse,
)

View file

@ -0,0 +1,42 @@
package com.tangem.datasource.api.marketing.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class MarketingCampaignsResponse(
@Json(name = "campaigns") val campaigns: List<CampaignDto>,
)
@JsonClass(generateAdapter = true)
data class CampaignDto(
@Json(name = "id") val id: Int,
@Json(name = "type") val type: String,
@Json(name = "priority") val priority: Int,
@Json(name = "startAt") val startAt: String? = null,
@Json(name = "endAt") val endAt: String? = null,
@Json(name = "minAmount") val minAmount: BigDecimal? = null,
@Json(name = "maxAmount") val maxAmount: BigDecimal? = null,
@Json(name = "providerIds") val providerIds: List<String>? = null,
@Json(name = "tokens") val tokens: List<CampaignTokenDto>? = null,
@Json(name = "banner") val banner: BannerDto,
)
@JsonClass(generateAdapter = true)
data class CampaignTokenDto(
@Json(name = "networkId") val networkId: String? = null,
@Json(name = "contractAddress") val contractAddress: String? = null,
@Json(name = "id") val id: String? = null,
)
@JsonClass(generateAdapter = true)
data class BannerDto(
@Json(name = "uiType") val uiType: String,
@Json(name = "text") val text: String? = null,
@Json(name = "icon") val icon: String? = null,
@Json(name = "iconAlign") val iconAlign: String? = null,
@Json(name = "bgColor") val bgColor: String? = null,
@Json(name = "deeplink") val deeplink: String? = null,
@Json(name = "dismissible") val isDismissible: Boolean = false,
)

View file

@ -44,11 +44,8 @@ data class OnrampItemResponse(
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
// todo txHistory uncomment
/*
@Json(name = "updatedAt")
val updatedAt: String,
*/
// endregion
// region fromAsset (fiat) info

View file

@ -17,12 +17,39 @@ interface TangemPayApi {
@Query("limit") limit: Int = TX_HISTORY_PAGING_DEFAULT_LIMIT,
): ApiResponse<TangemPayTxHistoryResponse>
@GET("v1/customer/transactions/{transaction_id}")
suspend fun getCustomerTransaction(
@Header("Authorization") authHeader: String,
@Path("transaction_id") transactionId: String,
): ApiResponse<TangemPayTransactionResponse>
@GET("v1/customer/kyc")
suspend fun getKycAccess(@Header("Authorization") authHeader: String): ApiResponse<KycAccessInfoResponse>
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
@GET("v1/customer/tariff-plan/transitions")
suspend fun getTariffPlanTransitions(
@Header("Authorization") authHeader: String,
): ApiResponse<TariffPlanTransitionsResponse>
@POST("v1/customer/tariff-plan/pending-transition")
suspend fun setPendingTariffPlanTransition(
@Header("Authorization") authHeader: String,
@Body body: SetPendingTariffPlanTransitionRequest,
): ApiResponse<Any>
@POST("v1/customer/tariff-plan/pending-transition/cancel")
suspend fun cancelPendingTariffPlanTransition(@Header("Authorization") authHeader: String): ApiResponse<Any>
/** Fiat bank requisites for the Virtual Account on-ramp (VA MVP0, TWI-1638). */
@GET("v1/account/bank-credentials/{product_instance_id}")
suspend fun getBankCredentials(
@Header("Authorization") authHeader: String,
@Path("product_instance_id") productInstanceId: String,
): ApiResponse<BankCredentialsResponse>
@GET("v1/customer/wallets/{customer_wallet_id}")
suspend fun checkCustomerWalletId(
@Path("customer_wallet_id") customerWalletId: String,
@ -40,6 +67,12 @@ interface TangemPayApi {
@GET("v1/eligibility/channels")
suspend fun getEligibilityChannels(): ApiResponse<TangemPayEligibilityChannels>
/** Eligibility channels fetched with the user (customer-wallet) token (VA MVP0, TWI-1638). */
@GET("v1/eligibility/channels")
suspend fun getUserEligibilityChannels(
@Header("Authorization") authHeader: String,
): ApiResponse<TangemPayEligibilityChannels>
@GET("v1/order/{order_id}")
suspend fun getOrder(
@Header("Authorization") authHeader: String,
@ -64,6 +97,19 @@ interface TangemPayApi {
@Body body: OrderRequest,
): ApiResponse<OrderResponse>
// TODO: Doston: [REDACTED_TASK_KEY] Unify with method above
@POST("v1/order")
suspend fun createVirtualAccountOrder(
@Header("Authorization") authHeader: String,
@Body body: VirtualAccountOrderRequest,
): ApiResponse<OrderResponse>
@POST("v1/order/{order_id}/cancel")
suspend fun cancelOrder(
@Header("Authorization") authHeader: String,
@Path("order_id") orderId: String,
): ApiResponse<Any>
/** Customer offers — used to gate the issue-additional-card flow. */
@GET("v1/customer/offers")
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse>

View file

@ -11,7 +11,9 @@ data class OrderRequest(
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
@Json(name = "specification_name") val specificationName: String = "SP_000004",
@Json(name = "type") val type: String = "CARD_ISSUE_VIRTUAL_RAIN_KYC",
@Json(name = "specification_name") val specificationName: String?,
@Json(name = "type") val type: String,
@Json(name = "target_tariff_plan_id") val targetTariffPlanId: String? = null,
@Json(name = "tariff_plan_transition_type") val tariffPlanTransitionType: String? = null,
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SetPendingTariffPlanTransitionRequest(
@Json(name = "pending_tariff_plan_id") val pendingTariffPlanId: String,
)

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating a Virtual Account on-ramp order (VA MVP0, TWI-1638).
*
* `wallet_address` is the customer's managing (collateral-managing) wallet address; `payment_account_address`
* is the existing collateral address. Distinct from the card-issue [OrderRequest] contract.
*/
@JsonClass(generateAdapter = true)
data class VirtualAccountOrderRequest(
@Json(name = "data") val data: Data,
@Json(name = "idempotency_key") val idempotencyKey: String,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "deposit_address") val depositAddress: String,
@Json(name = "type") val type: String = "ACCOUNT_ISSUE_VIRTUAL_RAIN",
@Json(name = "specification_name") val specificationName: String = "SP_000006",
)
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response of `bff-v2/v1/account/bank-credentials/{product_instance_id}` fiat bank requisites for the
* Virtual Account on-ramp (VA MVP0, TWI-1638).
*/
@JsonClass(generateAdapter = true)
data class BankCredentialsResponse(
@Json(name = "result") val result: Result?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "type") val type: String?,
@Json(name = "beneficiary_name") val beneficiaryName: String?,
@Json(name = "beneficiary_address") val beneficiaryAddress: String?,
@Json(name = "beneficiary_bank_name") val beneficiaryBankName: String?,
@Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?,
@Json(name = "account_number") val accountNumber: String?,
@Json(name = "routing_number") val routingNumber: String?,
)
}

View file

@ -21,13 +21,56 @@ data class CustomerMeResponse(
@Json(name = "balance") val balance: BalanceResponse?,
@Json(name = "product_instances") val productInstances: List<ProductInstance>,
@Json(name = "cards") val cards: List<Card>,
@Json(name = "customer_tariff_plan") val customerTariffPlan: CustomerTariffPlan? = null,
)
@JsonClass(generateAdapter = true)
data class CustomerTariffPlan(
@Json(name = "status") val status: String?,
@Json(name = "next_billing_at") val nextBillingAt: String?,
@Json(name = "pending_transition_at") val pendingTransitionAt: String?,
@Json(name = "tariff_plan") val tariffPlan: TariffPlan?,
@Json(name = "pending_tariff_plan") val pendingTariffPlan: TariffPlan?,
)
@JsonClass(generateAdapter = true)
data class TariffPlan(
@Json(name = "id") val id: String?,
@Json(name = "type") val type: String?,
@Json(name = "name") val name: String?,
@Json(name = "description_items") val descriptionItems: List<DescriptionItem>?,
@Json(name = "images") val images: List<Image>? = null,
@Json(name = "fees") val fees: List<Fee>? = null,
)
@JsonClass(generateAdapter = true)
data class Fee(
@Json(name = "type") val type: String?,
@Json(name = "amount") val amount: BigDecimal?,
@Json(name = "currency") val currency: String?,
@Json(name = "description") val description: String?,
@Json(name = "period") val period: String?,
)
@JsonClass(generateAdapter = true)
data class Image(
@Json(name = "type") val type: String?,
@Json(name = "url") val url: String?,
)
@JsonClass(generateAdapter = true)
data class DescriptionItem(
@Json(name = "type") val type: String?,
@Json(name = "order") val order: Int?,
@Json(name = "title") val title: String?,
@Json(name = "body") val body: String?,
)
@JsonClass(generateAdapter = true)
data class ProductInstance(
@Json(name = "id") val id: String,
@Json(name = "cid") val cid: String?,
@Json(name = "card_id") val cardId: String,
@Json(name = "card_id") val cardId: String?,
@Json(name = "card_wallet_address") val cardWalletAddress: String?,
@Json(name = "status") val status: Status,
@Json(name = "updated_at") val updatedAt: String,
@ -35,7 +78,17 @@ data class CustomerMeResponse(
@Json(name = "display_name") val displayName: String?,
@Json(name = "actual_card_limit") val actualCardLimit: CardLimit?,
@Json(name = "admin_card_limit") val adminCardLimit: CardLimit?,
@Json(name = "product_specification_data_type") val specificationDataType: SpecificationDataType,
) {
@JsonClass(generateAdapter = false)
enum class SpecificationDataType {
@Json(name = "ACCOUNT")
ACCOUNT,
@Json(name = "CARD")
CARD,
}
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "NEW")

View file

@ -27,6 +27,7 @@ data class OrderResponse(
@Json(name = "emboss_name") val embossName: String?,
@Json(name = "product_instance_id") val productInstanceId: String?,
@Json(name = "payment_account_id") val paymentAccountId: String?,
@Json(name = "target_tariff_plan_id") val targetTariffPlanId: String?,
@Json(name = "transaction_hash") val transactionHash: String?,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Response of `GET v1/customer/transactions/{transaction_id}` — a single transaction by its id. */
@JsonClass(generateAdapter = true)
data class TangemPayTransactionResponse(
@Json(name = "result") val result: TangemPayTxHistoryResponse.Transaction,
)

View file

@ -44,6 +44,8 @@ data class TangemPayTxHistoryResponse(
@Json(name = "enriched_merchant_category") val enrichedMerchantCategory: String? = null,
@Json(name = "card_id") val cardId: String? = null,
@Json(name = "card_type") val cardType: String? = null,
@Json(name = "card_display_name") val cardDisplayName: String? = null,
@Json(name = "card_number_end") val cardNumberEnd: String? = null,
@Json(name = "status") val status: String,
@Json(name = "declined_reason") val declinedReason: String? = null,
@Json(name = "authorized_at") val authorizedAt: DateTime,

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TariffPlanTransitionsResponse(
@Json(name = "result") val result: List<TariffPlanTransitionResponse>?,
)
@JsonClass(generateAdapter = true)
data class TariffPlanTransitionResponse(
@Json(name = "type") val type: String?,
@Json(name = "tariff_plan") val tariffPlan: CustomerMeResponse.TariffPlan?,
)

View file

@ -0,0 +1,18 @@
package com.tangem.datasource.api.promotion.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CreatePromotionRegistrationBody(
@Json(name = "campaignId") val campaignId: String,
@Json(name = "walletIds") val walletIds: List<String>,
@Json(name = "tokenReward") val tokenReward: TokenRewardDto,
) {
@JsonClass(generateAdapter = true)
data class TokenRewardDto(
@Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "networkId") val networkId: String,
)
}

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.promotion.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PromotionRegistrationResponse(
@Json(name = "status") val status: String,
@Json(name = "message") val message: String?,
@Json(name = "data") val data: RegistrationData,
) {
@JsonClass(generateAdapter = true)
data class RegistrationData(
@Json(name = "campaignId") val campaignId: String,
@Json(name = "registeredAt") val registeredAt: String?,
@Json(name = "tokenReward") val tokenReward: CreatePromotionRegistrationBody.TokenRewardDto,
)
}

View file

@ -1,6 +1,9 @@
package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.datasource.api.stories.models.StoryContentResponse
@ -46,22 +49,25 @@ interface TangemTechApi {
@GET("v1/geo")
suspend fun getUserCountryCode(): GeoResponse
@GET("v1/application/versions")
suspend fun getApplicationVersions(): ApiResponse<ApplicationVersionsResponse>
@PUT("/v1/wallets/{walletId}/tokens")
suspend fun saveTokens(
@Path(value = "walletId") userId: String,
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
@GET("/v1/wallets/{wallet_id}/notification-preferences")
@GET("/api/v1/notification-preferences/{wallet_id}")
suspend fun getPushNotificationPreferences(
@Path("wallet_id") walletId: String,
): ApiResponse<PushNotificationPreferencesResponse>
@PUT("/v1/wallets/{wallet_id}/notification-preferences")
@PUT("/api/v1/notification-preferences/{wallet_id}")
suspend fun updatePushNotificationPreferences(
@Path("wallet_id") walletId: String,
@Body body: PushNotificationPreferencesBody,
): ApiResponse<Unit>
): ApiResponse<PushNotificationPreferencesResponse>
// region Referral
/** Returns referral status by [walletId] */
@ -120,7 +126,7 @@ interface TangemTechApi {
@GET("v1/stories/{story_id}")
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
// region yield-boost promo
// region promotions
@GET("/v2/promotion")
suspend fun getPromotions(
@Query("walletId") walletId: String,
@ -130,6 +136,11 @@ interface TangemTechApi {
@Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite")
@GET("/v2/promotion/yield-apr-boost/status")
suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse<YieldBoostStatusResponse>
@POST("/v2/promotion/registrations")
suspend fun createPromotionRegistration(
@Body body: CreatePromotionRegistrationBody,
): ApiResponse<PromotionRegistrationResponse>
// endregion
// region push notifications
@ -235,4 +246,18 @@ interface TangemTechApi {
@GET("v1/earn/networks")
suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse>
// endregion
// region marketing
@GET("api/v1/marketing/campaigns")
suspend fun getMarketingCampaigns(
@Query("type") type: String,
@Query("language") language: String? = null,
@Query("fromNetwork") fromNetwork: String? = null,
@Query("fromContractAddress") fromContractAddress: String? = null,
@Query("toNetwork") toNetwork: String? = null,
@Query("toContractAddress") toContractAddress: String? = null,
@Query("fromFiat") fromFiat: String? = null,
@Header("If-None-Match") eTag: String? = null,
): ApiResponse<MarketingCampaignsResponse>
// endregion
}

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response of `GET v1/application/versions`. All fields are nullable the backend may omit any, in
* which case the corresponding check is skipped.
*
* @property minSupportedVersion app version threshold for a mandatory update: if
* `installedVersion <= minSupportedVersion` the app must force-update (or show "update your OS"
* when [minSupportedOSVersion] is not met). Inclusive. E.g. "5.30".
* @property minSupportedOSVersion minimal device OS version required to install the update for the
* [minSupportedVersion] case: if `deviceOsVersion < minSupportedOSVersion` the device can't update
* and the "OS too old" screen is shown. Exclusive.
* @property criticalVersion app version threshold for a critical mandatory update: if
* `installedVersion <= criticalVersion` the app must force-update (or permanently "brick" when
* [criticalOSVersion] is not met). Inclusive.
* @property criticalOSVersion minimal device OS version required to install the critical update:
* if `deviceOsVersion < criticalOSVersion` the app is bricked (update impossible). Exclusive.
* @property latestVersion latest available app version: if `installedVersion < latestVersion`
* an optional update is offered. Exclusive. E.g. "5.40".
*/
@JsonClass(generateAdapter = true)
data class ApplicationVersionsResponse(
@Json(name = "minSupportedVersion") val minSupportedVersion: String?,
@Json(name = "minSupportedOSVersion") val minSupportedOSVersion: String?,
@Json(name = "criticalVersion") val criticalVersion: String?,
@Json(name = "criticalOSVersion") val criticalOSVersion: String?,
@Json(name = "latestVersion") val latestVersion: String?,
)

View file

@ -1,10 +0,0 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PushNotificationPreferenceState(
@Json(name = "isEnabled") val isEnabled: Boolean,
@Json(name = "isVisible") val isVisible: Boolean,
)

View file

@ -5,10 +5,10 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PushNotificationPreferencesBody(
@Json(name = "transactionAlerts")
val areTransactionAlertsEnabled: Boolean,
@Json(name = "offersUpdates")
val areOffersUpdatesEnabled: Boolean,
@Json(name = "priceAlerts")
@Json(name = "transactionEventsEnabled")
val areTransactionEventsEnabled: Boolean,
@Json(name = "offerUpdatesEnabled")
val areOfferUpdatesEnabled: Boolean,
@Json(name = "priceAlertsEnabled")
val arePriceAlertsEnabled: Boolean,
)

View file

@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PushNotificationPreferencesResponse(
@Json(name = "transactionAlerts") val transactionAlerts: PushNotificationPreferenceState,
@Json(name = "offersUpdates") val offersUpdates: PushNotificationPreferenceState,
@Json(name = "priceAlerts") val priceAlerts: PushNotificationPreferenceState,
@Json(name = "transactionEventsEnabled") val areTransactionEventsEnabled: Boolean,
@Json(name = "offerUpdatesEnabled") val areOfferUpdatesEnabled: Boolean,
@Json(name = "priceAlertsEnabled") val arePriceAlertsEnabled: Boolean,
)

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
@ -18,6 +19,7 @@ import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.stakekit.StakeKitApi
@ -117,6 +119,16 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideAddressBookApi(retrofitApiBuilder: RetrofitApiBuilder): AddressBookApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@Provides
@Singleton
fun provideYieldSupplyApi(retrofitApiBuilder: RetrofitApiBuilder): YieldSupplyApi {
@ -252,4 +264,20 @@ internal object NetworkModule {
),
)
}
@Provides
@Singleton
fun provideGaslessTxServiceApiV2(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApiV2 {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
readTimeoutSeconds = TIMEOUT_60_SECONDS,
writeTimeoutSeconds = TIMEOUT_60_SECONDS,
),
)
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.di
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.promotion.DefaultPromotionsSupplier
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PromotionModule {
@Provides
@Singleton
fun providePromotionsSupplier(
tangemApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): PromotionsSupplier {
return DefaultPromotionsSupplier(
tangemApi = tangemApi,
store = RuntimeSharedStore<Map<UserWalletId, PromotionsResponse>>(),
dispatchers = dispatchers,
)
}
}

View file

@ -1,14 +1,27 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.txhistory.DefaultTxHistoryItemsStore
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.visa.DefaultTangemPayTxHistoryItemsStore
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import javax.inject.Singleton
@Module
@ -25,9 +38,34 @@ internal object TxHistoryItemsStoreModule {
@Provides
@Singleton
fun provideTangemPayTxHistoryItemsStore(): TangemPayTxHistoryItemsStore {
fun provideTangemPayTxHistoryItemsStore(
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
toDMConverter: TangemPayTxHistoryItemToDMConverter,
toDomainConverter: TangemPayTxHistoryItemToDomainConverter,
): TangemPayTxHistoryItemsStore {
return DefaultTangemPayTxHistoryItemsStore(
dataStore = RuntimeDataStore(),
dataStore = DataStoreFactory.create(
serializer = KotlinxDataStoreSerializer(
defaultValue = emptyMap(),
serializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = ListSerializer(TangemPayTxHistoryItemDM.serializer()),
),
),
// TangemPayTxHistoryItemDM.Collateral declares a `type` field that would clash with the
// default kotlinx polymorphic class discriminator ("type"), so persist sealed subtypes
// under a non-conflicting discriminator key.
json = KotlinxDataStoreSerializer.jsonBuilder { classDiscriminator = "__type" },
),
corruptionHandler = ReplaceFileCorruptionHandler { emptyMap() },
produceFile = { context.dataStoreFile(fileName = "tangem_pay_tx_history") },
scope = appScope,
),
toDMConverter = toDMConverter,
toDomainConverter = toDomainConverter,
)
}
}

View file

@ -5,6 +5,8 @@ import androidx.room.Room
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -37,5 +39,11 @@ internal interface TxHistoryModule {
@Provides
fun provideSyncStateDao(database: TxHistoryDatabase): ExpressSyncStateDao = database.syncStateDao()
@Provides
fun provideTokenInfoDao(database: TxHistoryDatabase): TokenInfoDao = database.tokenInfoDao()
@Provides
fun provideHistoryIndexDao(database: TxHistoryDatabase): HistoryIndexDao = database.historyIndexDao()
}
}

View file

@ -128,8 +128,13 @@ internal class RetrofitApiBuilder @Inject constructor(
// feature toggle is OFF we skip installing the hooks entirely (avoids wiring up DPoP
// header generation and 401 retry logic on builds where auth isn't live yet).
if (condition && isBackendAuthEnabled.get()) {
addInterceptor(sessionAuthInterceptor.get())
authenticator(sessionAuthenticator.get())
// Resolve the providers lazily, at request time, instead of eagerly here. The session
// authenticator depends (via SessionTokenRefresher) back on AuthApi, so calling `.get()`
// while AuthApi is still being built would recurse into provideAuthApi → applySessionAuth
// → `.get()` → … and overflow the stack. Deferring `.get()` to the first HTTP call lets
// AuthApi finish constructing (and get cached) first, breaking the cycle.
addInterceptor(Interceptor { chain -> sessionAuthInterceptor.get().intercept(chain) })
authenticator { route, response -> sessionAuthenticator.get().authenticate(route, response) }
}
return this

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.local.appsflyer
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import kotlinx.coroutines.flow.Flow
interface AppsFlyerStore {
@ -14,20 +15,11 @@ interface AppsFlyerStore {
suspend fun storeUIDIfAbsent(value: String)
suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String?
fun observeNavigationDeeplink(): Flow<String?>
suspend fun storeDeeplink(source: AppsFlyerDeeplinkSource, deeplink: String)
suspend fun getNavigationDeeplink(): String?
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
}
suspend fun storeNavigationDeeplink(deepLinkValue: String)
enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding,
Referral,
;
fun toStoreKey() = when (this) {
TangemPayHotWalletOnboarding -> "tangem_pay_hot_wallet_onboarding"
Referral -> "referral"
}
suspend fun clearNavigationDeeplink()
}

View file

@ -9,6 +9,9 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
internal class DefaultAppsFlyerStore(
private val appPreferencesStore: AppPreferencesStore,
@ -56,24 +59,28 @@ internal class DefaultAppsFlyerStore(
}
}
override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? =
appPreferencesStore.getSyncOrNull(stringPreferencesKey(source.toStoreKey()))
override fun observeNavigationDeeplink(): Flow<String?> = appPreferencesStore.data
.map { preferences -> preferences[NAVIGATION_DEEPLINK_KEY] }
.distinctUntilChanged()
override suspend fun storeDeeplink(source: AppsFlyerDeeplinkSource, deeplink: String) {
override suspend fun getNavigationDeeplink(): String? = appPreferencesStore.getSyncOrNull(NAVIGATION_DEEPLINK_KEY)
override suspend fun storeNavigationDeeplink(deepLinkValue: String) {
appPreferencesStore.editData { preferences ->
preferences[stringPreferencesKey(source.toStoreKey())] = deeplink
preferences[NAVIGATION_DEEPLINK_KEY] = deepLinkValue
}
}
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
override suspend fun clearNavigationDeeplink() {
appPreferencesStore.editData { preferences ->
preferences.remove(stringPreferencesKey(source.toStoreKey()))
preferences.remove(NAVIGATION_DEEPLINK_KEY)
}
}
private companion object {
val UID_KEY = stringPreferencesKey("APPS_FLYER_UID")
val CONVERSION_DATA_KEY = stringPreferencesKey("APPS_FLYER_CONVERSION_DATA")
val NAVIGATION_DEEPLINK_KEY = stringPreferencesKey("appsflyer_navigation_deeplink")
}
}

View file

@ -7,13 +7,13 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEnti
/**
* Maps API history items into their persisted [androidx.room.Entity] representations.
*
* @param ownerAddress address the history was requested for. Stored as the query key.
*/
fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity {
fun ExchangeItemResponse.toEntity(): ExpressExchangeEntity? {
// Items with no fromAddress (very old app versions didn't send it) can't be found by the outgoing-swap
// lookup, which keys on from_address — drop them. Such items are effectively nonexistent nowadays.
if (fromAddress == null) return null
return ExpressExchangeEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
fromAddress = fromAddress,
payinAddress = payinAddress,
@ -30,7 +30,7 @@ fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity {
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = createdAt,
updatedAt = ""/*updatedAt*/, // todo txHistory uncomment
updatedAt = updatedAt,
payTill = payTill,
averageDuration = averageDuration,
from = ExpressExchangeEntity.AssetEmbedded(
@ -50,10 +50,9 @@ fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity {
)
}
fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity {
fun OnrampItemResponse.toEntity(): ExpressOnrampEntity {
return ExpressOnrampEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
payoutAddress = payoutAddress,
status = status,
@ -62,7 +61,7 @@ fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity {
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = createdAt,
updatedAt = ""/*updatedAt*/, // todo txHistory uncomment,
updatedAt = updatedAt,
fromCurrencyCode = fromCurrencyCode,
fromAmount = fromAmount,
fromPrecision = fromPrecision,

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.local.converter
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
/** Maps an [OnrampCountryDTO] API response into its persisted [OnrampCountryEntity]. */
fun OnrampCountryDTO.toEntity(): OnrampCountryEntity {
return OnrampCountryEntity(
code = code,
name = name,
image = image,
alpha3 = alpha3,
continent = continent,
isOnrampAvailable = onrampAvailable,
defaultCurrency = OnrampCountryEntity.CurrencyEmbedded(
name = defaultCurrency.name,
code = defaultCurrency.code,
image = defaultCurrency.image,
precision = defaultCurrency.precision,
unit = defaultCurrency.unit ?: defaultCurrency.code,
),
)
}

View file

@ -33,6 +33,16 @@ object PreferencesKeys {
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }
val LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY by lazy {
stringPreferencesKey(name = "lastOptionalUpdateShownVersion")
}
val LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY by lazy { longPreferencesKey(name = "lastOptionalUpdateShownAt") }
val CACHED_APP_VERSIONS_KEY by lazy { stringPreferencesKey(name = "cachedApplicationVersions") }
val CACHED_APP_VERSIONS_AT_KEY by lazy { longPreferencesKey(name = "cachedApplicationVersionsAt") }
val SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "showRatingDialogAtLaunchCount") }
val FUNDS_FOUND_DATE_KEY by lazy { longPreferencesKey(name = "fundsFoundDate") }
@ -41,6 +51,8 @@ object PreferencesKeys {
val USED_CARDS_INFO_KEY by lazy { stringPreferencesKey(name = "usedCardsInfo_v2") }
val USEDESK_CLIENT_ID_KEY by lazy { stringPreferencesKey(name = "usedeskClientId") }
val APP_THEME_MODE_KEY by lazy { stringPreferencesKey(name = "appThemeMode") }
val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") }
@ -105,6 +117,9 @@ object PreferencesKeys {
val IS_DEVICE_REGISTERED_KEY by lazy { booleanPreferencesKey(name = "isDeviceRegistered") }
/** Base64 `UserWalletId`s already registered with the Tangem Auth Service (`/auth/wallet`). */
val REGISTERED_WALLET_IDS_KEY by lazy { stringSetPreferencesKey(name = "registeredWalletIds") }
val WAS_LOG_FILE_CLEARED by lazy { booleanPreferencesKey(name = "wasLogFileCleared") }
val SEED_FIRST_NOTIFICATION_SHOW_TIME by lazy { longPreferencesKey("seedFirstNotificationTime") }
@ -129,6 +144,8 @@ object PreferencesKeys {
val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") }
val PROMO_ENROLLMENTS_KEY by lazy { stringPreferencesKey(name = "promoEnrollments") }
// region Notifications
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }
@ -146,6 +163,10 @@ object PreferencesKeys {
)
}
val PUSH_NOTIFICATION_FIRST_ACTIVATION_DONE_WALLET_IDS_KEY by lazy {
stringSetPreferencesKey(name = "pushNotificationFirstActivationDoneWalletIds")
}
val TRON_NETWORK_FEE_NOTIFICATION_SHOW_COUNT_KEY by lazy {
intPreferencesKey(name = "tronNetworkFeeNotificationShowCount")
}
@ -154,7 +175,7 @@ object PreferencesKeys {
val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy {
stringPreferencesKey(name = "tangemPayActiveWithdrawOrdersKey")
}
val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityList") }
val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityListV2") }
fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key")
// endregion
@ -189,6 +210,9 @@ object PreferencesKeys {
fun getTangemPayOrderIdKey(customerWalletAddress: String) =
stringPreferencesKey("tangem_pay_order_id_key_$customerWalletAddress")
fun getTangemPayVirtualAccountOrderIdKey(customerWalletAddress: String) =
stringPreferencesKey("tangem_pay_va_order_id_key_$customerWalletAddress")
fun getTangemPayCustomerWalletAddressKey(userWalletId: UserWalletId) =
stringPreferencesKey("tangem_pay_customer_wallet_address_key_${userWalletId.stringValue}")

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.local.promotion
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultPromotionsSupplier(
private val tangemApi: TangemTechApi,
private val store: RuntimeSharedStore<Map<UserWalletId, PromotionsResponse>>,
private val dispatchers: CoroutineDispatcherProvider,
) : PromotionsSupplier {
override suspend fun getPromotions(userWalletId: UserWalletId, forceRefresh: Boolean): PromotionsResponse {
if (!forceRefresh) {
store.getSyncOrNull()?.get(userWalletId)?.let { return it }
}
val fresh = withContext(dispatchers.io) {
tangemApi.getPromotions(walletId = userWalletId.stringValue).getOrThrow()
}
store.update(emptyMap()) { it + (userWalletId to fresh) }
return fresh
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.local.promotion
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.domain.models.wallet.UserWalletId
/**
* Shared cache-first fetch of GET /v2/promotion. Keeps one in-memory entry per [UserWalletId]:
* a non-forced call returns the cached response when present, otherwise it fetches. A fetch failure
* propagates to the caller (no stale-cache fallback), so the caller decides how to handle it.
*/
interface PromotionsSupplier {
@Throws(Exception::class)
suspend fun getPromotions(userWalletId: UserWalletId, forceRefresh: Boolean = false): PromotionsResponse
}

View file

@ -4,10 +4,15 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
@Database(
version = 1,
@ -16,6 +21,9 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEn
ExpressExchangeEntity::class,
ExpressOnrampEntity::class,
ExpressSyncStateEntity::class,
OnrampCountryEntity::class,
TokenInfoEntity::class,
HistoryIndexEntity::class,
],
)
abstract class TxHistoryDatabase : RoomDatabase() {
@ -23,4 +31,8 @@ abstract class TxHistoryDatabase : RoomDatabase() {
abstract fun expressHistoryDao(): ExpressHistoryDao
abstract fun syncStateDao(): ExpressSyncStateDao
abstract fun tokenInfoDao(): TokenInfoDao
abstract fun historyIndexDao(): HistoryIndexDao
}

View file

@ -8,6 +8,7 @@ import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import kotlinx.coroutines.flow.Flow
@Dao
@ -22,15 +23,22 @@ interface ExpressHistoryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertOnramps(items: List<ExpressOnrampEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertCountries(items: List<OnrampCountryEntity>)
/**
* All persisted providers keyed by [ExpressProviderEntity.id]
*/
@Query("SELECT * FROM express_provider")
fun getProvidersById(): Flow<Map<@MapColumn(columnName = "id") String, ExpressProviderEntity>>
/** All persisted onramp countries keyed by [OnrampCountryEntity.code]. */
@Query("SELECT * FROM onramp_country")
fun getCountriesByCode(): Flow<Map<@MapColumn(columnName = "code") String, OnrampCountryEntity>>
/**
* Outgoing swaps: the viewed currency is the swap's `from` side, so the row is stored under this
* address ([ExpressExchangeEntity.ownerAddress] == fromAddress). Join to on-chain by `payin_hash`.
* Outgoing swaps: the viewed currency is the swap's `from` side, so the row is looked up by its `from_address`.
* Join to on-chain by `payin_hash`.
*
* loading the whole table; [activeStatuses] keeps in-progress deals visible even outside the window.
@ -38,7 +46,7 @@ interface ExpressHistoryDao {
@Query(
"""
SELECT * FROM express_exchange
WHERE owner_address = :ownerAddress
WHERE from_address = :fromAddress
AND from_network = :network
AND from_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
@ -46,7 +54,7 @@ interface ExpressHistoryDao {
""",
)
fun observeOutgoingSwaps(
ownerAddress: String,
fromAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
@ -54,20 +62,21 @@ interface ExpressHistoryDao {
): Flow<List<ExpressExchangeEntity>>
/**
* Incoming swaps: the viewed currency is the swap's `to` side. Such a deal was initiated from a
* different coin, so the row is stored under that coin's `owner_address` hence this query is
* cross-owner, matched by the `to` asset. Join to on-chain by `payout_hash`.
* Incoming swaps: the viewed currency is the swap's `to` side, so the row is looked up by its `payout_address`
* (where the target assets landed = this currency's address). Join to on-chain by `payout_hash`.
*/
@Query(
"""
SELECT * FROM express_exchange
WHERE to_network = :network
WHERE payout_address = :payoutAddress
AND to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeIncomingSwaps(
payoutAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
@ -75,12 +84,12 @@ interface ExpressHistoryDao {
): Flow<List<ExpressExchangeEntity>>
/**
* Onramp is always incoming: [ExpressOnrampEntity.ownerAddress] == payoutAddress. Join by `payout_hash`.
* Onramp is always incoming, looked up by its `payout_address`. Join by `payout_hash`.
*/
@Query(
"""
SELECT * FROM express_onramp
WHERE owner_address = :ownerAddress
WHERE payout_address = :payoutAddress
AND to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
@ -88,7 +97,7 @@ interface ExpressHistoryDao {
""",
)
fun observeIncomingOnramps(
ownerAddress: String,
payoutAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,

View file

@ -0,0 +1,74 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface HistoryIndexDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(items: List<HistoryIndexEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(item: HistoryIndexEntity)
/**
* One page of the unified timeline for [addresses] (usually one, but some token-details screens span several),
* newest first, **one row per operation**. An operation indexed under several of the queried [addresses] (e.g. a
* swap under both its from- and payout-address) is collapsed via `GROUP BY (type, entity_id)`, keeping its
* most-recent occurrence (SQLite bare-column rule under a single `MAX`). This both de-duplicates the timeline and
* makes the keyset key (`sort_time_millis`, `entity_id`) unique, so rows are neither skipped nor duplicated across
* page boundaries even when several addresses are queried.
*
* The cursor is the (`sort_time_millis`, `entity_id`) of the last (oldest) row of the previous page pass both
* [cursorSortTimeMillis] and [cursorEntityId], or `null` for the first page.
*/
@Query(
"""
SELECT type, entity_id, address, MAX(sort_time_millis) AS sort_time_millis FROM history_index
WHERE address IN (:addresses)
GROUP BY type, entity_id
HAVING (
:cursorSortTimeMillis IS NULL
OR MAX(sort_time_millis) < :cursorSortTimeMillis
OR (MAX(sort_time_millis) = :cursorSortTimeMillis AND entity_id < :cursorEntityId)
)
ORDER BY sort_time_millis DESC, entity_id DESC
LIMIT :limit
""",
)
fun observePage(
addresses: List<String>,
cursorSortTimeMillis: Long?,
cursorEntityId: String?,
limit: Int,
): Flow<List<HistoryIndexEntity>>
fun observePage(addresses: List<String>, cursor: Cursor?, limit: Int): Flow<List<HistoryIndexEntity>> = observePage(
addresses = addresses,
cursorSortTimeMillis = cursor?.sortTimeMillis,
cursorEntityId = cursor?.entityId,
limit = limit,
)
/**
* Keyset cursor for [observePage]: the (sortTimeMillis, entityId) of the last (oldest) row of a page. Build it from
* the previous page's last row to fetch the next page; a `null` cursor requests the first page.
*/
data class Cursor(
val sortTimeMillis: Long,
val entityId: String,
) {
companion object {
fun from(lastRow: HistoryIndexEntity): Cursor = Cursor(
sortTimeMillis = lastRow.sortTimeMillis,
entityId = lastRow.entityId,
)
}
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
@Dao
interface TokenInfoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(items: List<TokenInfoEntity>)
/**
* Cached tokens for the given networks/contracts. Filters each column independently, so the result is a
* cross-product superset of the `(networkId, contractAddress)` pairs the caller must match exact pairs.
* Contract match is case-insensitive; pass [minUpdatedAt] = `now - ttl` to drop stale rows.
*/
@Query(
"""
SELECT * FROM token_info
WHERE network_id IN (:networkIds)
AND contract_address COLLATE NOCASE IN (:contractAddresses)
AND updated_at >= :minUpdatedAt
""",
)
suspend fun getCached(
networkIds: Collection<String>,
contractAddresses: Collection<String>,
minUpdatedAt: Long = 0,
): List<TokenInfoEntity>
}

View file

@ -0,0 +1,42 @@
package com.tangem.datasource.local.txhistory.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
/**
* Unified pagination index over the local history sources.
*/
@Entity(
tableName = "history_index",
// A single row (type + entity_id) may be shown under more than one address (e.g. a swap between two owned tokens
// appears under both), so the address is part of the identity.
primaryKeys = ["type", "entity_id", "address"],
indices = [
// Newest-first cursor scan within an address: address filter + sort-time ordering + entity_id tie-break.
Index(value = ["address", "sort_time_millis", "entity_id"]),
],
)
data class HistoryIndexEntity(
@ColumnInfo(name = "type")
val type: String,
/** ID of the row in its own table. */
@ColumnInfo(name = "entity_id")
val entityId: String,
/** Address the row is loaded under on the token-details screen. */
@ColumnInfo(name = "address")
val address: String,
/** Time the unified timeline is sorted by (newest first). */
@ColumnInfo(name = "sort_time_millis")
val sortTimeMillis: Long,
) {
enum class Type(val value: String) {
EXCHANGE(value = "EXCHANGE"),
ONRAMP(value = "ONRAMP"),
}
}

View file

@ -10,11 +10,10 @@ import androidx.room.*
@Entity(
tableName = "express_exchange",
indices = [
// Outgoing swaps lookup (observeOutgoingSwaps): owner + from-asset equality, created_at range/sort.
Index(value = ["owner_address", "from_network", "from_contract_address", "created_at"]),
// Incoming (cross-owner) swaps lookup (observeIncomingSwaps): to-asset equality, created_at range/sort.
// No owner filter here, so to_contract_address in the index is what keeps a popular to-network selective.
Index(value = ["to_network", "to_contract_address", "created_at"]),
// Outgoing swaps lookup (observeOutgoingSwaps): from-address + from-asset equality, created_at range/sort.
Index(value = ["from_address", "from_network", "from_contract_address", "created_at"]),
// Incoming swaps lookup (observeIncomingSwaps): payout-address + to-asset equality, created_at range/sort.
Index(value = ["payout_address", "to_network", "to_contract_address", "created_at"]),
],
)
data class ExpressExchangeEntity(
@ -23,21 +22,16 @@ data class ExpressExchangeEntity(
@ColumnInfo(name = "tx_id")
val txId: String,
/**
* Address used to query the history. For exchange it matches [fromAddress].
*/
@ColumnInfo(name = "owner_address")
val ownerAddress: String,
@ColumnInfo(name = "provider_id")
val providerId: String,
/**
* Address from which the `from` assets were taken for the exchange. Optional because the very first
* app versions did not send it; for newer versions it can be considered effectively mandatory.
* Address from which the `from` assets were taken the key outgoing swaps are looked up by. The API may omit it
* (the very first app versions did not send it), but such items are filtered out before persisting, so the stored
* value is always present.
*/
@ColumnInfo(name = "from_address")
val fromAddress: String?,
val fromAddress: String,
/** Address to which the source assets were transferred for the exchange */
@ColumnInfo(name = "payin_address")

View file

@ -10,8 +10,8 @@ import androidx.room.*
@Entity(
tableName = "express_onramp",
indices = [
// Incoming onramp lookup (observeIncomingOnramps): owner + to-asset equality, created_at range/sort.
Index(value = ["owner_address", "to_network", "to_contract_address", "created_at"]),
// Incoming onramp lookup (observeIncomingOnramps): payout-address + to-asset equality, created_at range/sort.
Index(value = ["payout_address", "to_network", "to_contract_address", "created_at"]),
],
)
data class ExpressOnrampEntity(
@ -20,16 +20,10 @@ data class ExpressOnrampEntity(
@ColumnInfo(name = "tx_id")
val txId: String,
/**
* Address used to query the history. For onramp it matches [payoutAddress].
*/
@ColumnInfo(name = "owner_address")
val ownerAddress: String,
@ColumnInfo(name = "provider_id")
val providerId: String,
/** Address that received the target assets */
/** Address that received the target assets — the key incoming onramps are looked up by. */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,

View file

@ -0,0 +1,52 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
/** Persisted onramp country, matched to a transaction by [code] == [ExpressOnrampEntity.countryCode]. */
@Entity(tableName = "onramp_country")
data class OnrampCountryEntity(
@PrimaryKey
@ColumnInfo(name = "code")
val code: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "image")
val image: String,
@ColumnInfo(name = "alpha3")
val alpha3: String,
@ColumnInfo(name = "continent")
val continent: String,
@ColumnInfo(name = "onramp_available")
val isOnrampAvailable: Boolean,
@Embedded(prefix = "currency_")
val defaultCurrency: CurrencyEmbedded,
) {
data class CurrencyEmbedded(
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "code")
val code: String,
@ColumnInfo(name = "image")
val image: String?,
@ColumnInfo(name = "precision")
val precision: Int,
@ColumnInfo(name = "unit")
val unit: String,
)
}

View file

@ -0,0 +1,37 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Entity
/**
* Cached token data fetched from [TangemTechApi.getCoins], keyed by network id + contract address.
*/
@Entity(
tableName = "token_info",
primaryKeys = ["network_id", "contract_address"],
)
data class TokenInfoEntity(
@ColumnInfo(name = "network_id")
val networkId: String,
@ColumnInfo(name = "contract_address")
val contractAddress: String,
/** Coin id from the backend (used as the token's raw currency id and for the icon URL). */
@ColumnInfo(name = "coin_id")
val coinId: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "symbol")
val symbol: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
/** Last refresh timestamp in epoch milliseconds, used to evict stale entries. */
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
)

View file

@ -10,15 +10,15 @@ internal class DefaultTangemPayCloseCardStore(
private val prefs: AppPreferencesStore,
) : TangemPayCloseCardStore {
override suspend fun setCloseOrderId(cardId: String, orderId: String?) {
if (orderId == null) {
prefs.edit { it.remove(getCloseKey(cardId)) }
} else {
prefs.store(
key = getCloseKey(cardId),
value = orderId,
)
}
override suspend fun storeCloseOrderId(cardId: String, orderId: String) {
prefs.store(
key = getCloseKey(cardId),
value = orderId,
)
}
override suspend fun removeCloseOrderId(cardId: String) {
prefs.edit { it.remove(getCloseKey(cardId)) }
}
override suspend fun getOrderId(cardId: String): String? {

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -31,6 +32,10 @@ internal class DefaultTangemPayReissueCardStore(
)
}
override suspend fun removeReissueOrderId(cardId: String) {
prefs.edit { it.remove(getReissueKey(cardId)) }
}
override suspend fun getOrderId(cardId: String): String? {
return prefs.getSyncOrNull(key = getReissueKey(cardId))
}

View file

@ -1,23 +1,37 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.datasource.local.datastore.core.StringKeyDataStoreDecorator
import androidx.datastore.core.DataStore
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import kotlinx.coroutines.flow.first
internal typealias StoredTangemPayTxHistory = Map<String, Map<String, List<TangemPayTxHistoryItemDM>>>
internal class DefaultTangemPayTxHistoryItemsStore(
dataStore: StringKeyDataStore<Map<String, List<TangemPayTxHistoryItem>>>,
) : TangemPayTxHistoryItemsStore,
StringKeyDataStoreDecorator<String, Map<String, List<TangemPayTxHistoryItem>>>(dataStore) {
override fun provideStringKey(key: String): String = key
private val dataStore: DataStore<StoredTangemPayTxHistory>,
private val toDMConverter: TangemPayTxHistoryItemToDMConverter,
private val toDomainConverter: TangemPayTxHistoryItemToDomainConverter,
) : TangemPayTxHistoryItemsStore {
override suspend fun getSyncOrNull(key: String, cursor: String): List<TangemPayTxHistoryItem>? {
val storedValue = getSyncOrNull(key)
return storedValue?.get(cursor)
return dataStore.data.first()[key]?.get(cursor)?.let { toDomainConverter.convertList(it) }
}
override suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>) {
val oldValue = getSyncOrNull(key).orEmpty()
val newValue = oldValue.toMutableMap().apply { put(cursor, value) }
store(key, newValue)
val page = toDMConverter.convertList(value)
dataStore.updateData { stored ->
val walletPages = stored[key].orEmpty()
stored + (key to walletPages + (cursor to page))
}
}
override suspend fun remove(key: String) {
remove(keys = listOf(key))
}
override suspend fun remove(keys: List<String>) {
dataStore.updateData { stored -> stored - keys.toSet() }
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.datasource.local.visa
interface TangemPayCloseCardStore {
suspend fun setCloseOrderId(cardId: String, orderId: String?)
suspend fun storeCloseOrderId(cardId: String, orderId: String)
suspend fun removeCloseOrderId(cardId: String)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -11,5 +11,7 @@ interface TangemPayReissueCardStore {
suspend fun storeReissueOrderId(cardId: String, orderId: String)
suspend fun removeReissueOrderId(cardId: String)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -23,6 +23,12 @@ interface TangemPayStorage {
suspend fun clearOrderId(customerWalletAddress: String)
suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String)
suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String?
suspend fun clearVirtualAccountOrderId(customerWalletAddress: String)
suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean
suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean)

View file

@ -8,5 +8,7 @@ interface TangemPayTxHistoryItemsStore {
suspend fun remove(key: String)
suspend fun remove(keys: List<String>)
suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>)
}

View file

@ -0,0 +1,102 @@
package com.tangem.datasource.local.visa.entity
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.serialization.SerializedCurrency
import com.tangem.domain.models.serialization.SerializedDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal sealed class TangemPayTxHistoryItemDM {
abstract val id: String
abstract val date: SerializedDateTime
abstract val amount: SerializedBigDecimal
abstract val currency: SerializedCurrency
abstract val jsonRepresentation: String
@Serializable
@SerialName("spend")
data class Spend(
@SerialName("id") override val id: String,
@SerialName("json_representation") override val jsonRepresentation: String,
@SerialName("date") override val date: SerializedDateTime,
@SerialName("amount") override val amount: SerializedBigDecimal,
@SerialName("currency") override val currency: SerializedCurrency,
@SerialName("authorized_amount") val authorizedAmount: SerializedBigDecimal,
@SerialName("local_amount") val localAmount: SerializedBigDecimal?,
@SerialName("local_currency") val localCurrency: SerializedCurrency?,
@SerialName("enriched_merchant_name") val enrichedMerchantName: String?,
@SerialName("merchant_name") val merchantName: String,
@SerialName("enriched_merchant_category") val enrichedMerchantCategory: String?,
@SerialName("merchant_category_code") val merchantCategoryCode: String?,
@SerialName("merchant_category") val merchantCategory: String?,
@SerialName("status") val status: Status,
@SerialName("enriched_merchant_icon_url") val enrichedMerchantIconUrl: String?,
@SerialName("declined_reason") val declinedReason: String?,
) : TangemPayTxHistoryItemDM()
@Serializable
@SerialName("payment")
data class Payment(
@SerialName("id") override val id: String,
@SerialName("json_representation") override val jsonRepresentation: String,
@SerialName("date") override val date: SerializedDateTime,
@SerialName("amount") override val amount: SerializedBigDecimal,
@SerialName("currency") override val currency: SerializedCurrency,
@SerialName("transaction_hash") val transactionHash: String?,
) : TangemPayTxHistoryItemDM()
@Serializable
@SerialName("fee")
data class Fee(
@SerialName("id") override val id: String,
@SerialName("json_representation") override val jsonRepresentation: String,
@SerialName("date") override val date: SerializedDateTime,
@SerialName("amount") override val amount: SerializedBigDecimal,
@SerialName("currency") override val currency: SerializedCurrency,
@SerialName("description") val description: String?,
) : TangemPayTxHistoryItemDM()
@Serializable
@SerialName("collateral")
data class Collateral(
@SerialName("id") override val id: String,
@SerialName("json_representation") override val jsonRepresentation: String,
@SerialName("date") override val date: SerializedDateTime,
@SerialName("amount") override val amount: SerializedBigDecimal,
@SerialName("currency") override val currency: SerializedCurrency,
@SerialName("transaction_hash") val transactionHash: String,
@SerialName("type") val type: Type,
) : TangemPayTxHistoryItemDM()
@Serializable
enum class Type {
@SerialName("deposit")
Deposit,
@SerialName("withdrawal")
Withdrawal,
}
@Serializable
enum class Status {
@SerialName("pending")
PENDING,
@SerialName("reserved")
RESERVED,
@SerialName("completed")
COMPLETED,
@SerialName("declined")
DECLINED,
@SerialName("reversed")
REVERSED,
@SerialName("unknown")
UNKNOWN,
}
}

View file

@ -0,0 +1,133 @@
package com.tangem.datasource.local.visa.entity
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.utils.converter.Converter
import javax.inject.Inject
internal class TangemPayTxHistoryItemToDMConverter @Inject constructor() :
Converter<TangemPayTxHistoryItem, TangemPayTxHistoryItemDM> {
override fun convert(value: TangemPayTxHistoryItem): TangemPayTxHistoryItemDM = when (value) {
is TangemPayTxHistoryItem.Spend -> TangemPayTxHistoryItemDM.Spend(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
authorizedAmount = value.authorizedAmount,
localAmount = value.localAmount,
localCurrency = value.localCurrency,
enrichedMerchantName = value.enrichedMerchantName,
merchantName = value.merchantName,
enrichedMerchantCategory = value.enrichedMerchantCategory,
merchantCategoryCode = value.merchantCategoryCode,
merchantCategory = value.merchantCategory,
status = value.status.toDM(),
enrichedMerchantIconUrl = value.enrichedMerchantIconUrl,
declinedReason = value.declinedReason,
)
is TangemPayTxHistoryItem.Payment -> TangemPayTxHistoryItemDM.Payment(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
transactionHash = value.transactionHash,
)
is TangemPayTxHistoryItem.Fee -> TangemPayTxHistoryItemDM.Fee(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
description = value.description,
)
is TangemPayTxHistoryItem.Collateral -> TangemPayTxHistoryItemDM.Collateral(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
transactionHash = value.transactionHash,
type = value.type.toDM(),
)
}
}
internal class TangemPayTxHistoryItemToDomainConverter @Inject constructor() :
Converter<TangemPayTxHistoryItemDM, TangemPayTxHistoryItem> {
override fun convert(value: TangemPayTxHistoryItemDM): TangemPayTxHistoryItem = when (value) {
is TangemPayTxHistoryItemDM.Spend -> TangemPayTxHistoryItem.Spend(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
authorizedAmount = value.authorizedAmount,
localAmount = value.localAmount,
localCurrency = value.localCurrency,
enrichedMerchantName = value.enrichedMerchantName,
merchantName = value.merchantName,
enrichedMerchantCategory = value.enrichedMerchantCategory,
merchantCategoryCode = value.merchantCategoryCode,
merchantCategory = value.merchantCategory,
status = value.status.toDomain(),
enrichedMerchantIconUrl = value.enrichedMerchantIconUrl,
declinedReason = value.declinedReason,
)
is TangemPayTxHistoryItemDM.Payment -> TangemPayTxHistoryItem.Payment(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
transactionHash = value.transactionHash,
)
is TangemPayTxHistoryItemDM.Fee -> TangemPayTxHistoryItem.Fee(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
description = value.description,
)
is TangemPayTxHistoryItemDM.Collateral -> TangemPayTxHistoryItem.Collateral(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
transactionHash = value.transactionHash,
type = value.type.toDomain(),
)
}
}
private fun TangemPayTxHistoryItem.Status.toDM(): TangemPayTxHistoryItemDM.Status = when (this) {
TangemPayTxHistoryItem.Status.PENDING -> TangemPayTxHistoryItemDM.Status.PENDING
TangemPayTxHistoryItem.Status.RESERVED -> TangemPayTxHistoryItemDM.Status.RESERVED
TangemPayTxHistoryItem.Status.COMPLETED -> TangemPayTxHistoryItemDM.Status.COMPLETED
TangemPayTxHistoryItem.Status.DECLINED -> TangemPayTxHistoryItemDM.Status.DECLINED
TangemPayTxHistoryItem.Status.REVERSED -> TangemPayTxHistoryItemDM.Status.REVERSED
TangemPayTxHistoryItem.Status.UNKNOWN -> TangemPayTxHistoryItemDM.Status.UNKNOWN
}
private fun TangemPayTxHistoryItemDM.Status.toDomain(): TangemPayTxHistoryItem.Status = when (this) {
TangemPayTxHistoryItemDM.Status.PENDING -> TangemPayTxHistoryItem.Status.PENDING
TangemPayTxHistoryItemDM.Status.RESERVED -> TangemPayTxHistoryItem.Status.RESERVED
TangemPayTxHistoryItemDM.Status.COMPLETED -> TangemPayTxHistoryItem.Status.COMPLETED
TangemPayTxHistoryItemDM.Status.DECLINED -> TangemPayTxHistoryItem.Status.DECLINED
TangemPayTxHistoryItemDM.Status.REVERSED -> TangemPayTxHistoryItem.Status.REVERSED
TangemPayTxHistoryItemDM.Status.UNKNOWN -> TangemPayTxHistoryItem.Status.UNKNOWN
}
private fun TangemPayTxHistoryItem.Type.toDM(): TangemPayTxHistoryItemDM.Type = when (this) {
TangemPayTxHistoryItem.Type.Deposit -> TangemPayTxHistoryItemDM.Type.Deposit
TangemPayTxHistoryItem.Type.Withdrawal -> TangemPayTxHistoryItemDM.Type.Withdrawal
}
private fun TangemPayTxHistoryItemDM.Type.toDomain(): TangemPayTxHistoryItem.Type = when (this) {
TangemPayTxHistoryItemDM.Type.Deposit -> TangemPayTxHistoryItem.Type.Deposit
TangemPayTxHistoryItemDM.Type.Withdrawal -> TangemPayTxHistoryItem.Type.Withdrawal
}

View file

@ -47,6 +47,7 @@ class WireMockRedirectInterceptor : Interceptor {
"deep-index.moralis.io",
"solana-gateway.moralis.io",
"api.etherscan.io",
"eth-blockbook.nownodes.io",
)
/**

View file

@ -0,0 +1,119 @@
package com.tangem.datasource.local.promotion
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.io.IOException
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultPromotionsSupplierTest {
private val tangemApi: TangemTechApi = mockk()
private fun newSupplier() = DefaultPromotionsSupplier(
tangemApi = tangemApi,
store = RuntimeSharedStore(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("abcdef012345")
private val response = PromotionsResponse(promotions = emptyList())
private val response2 = PromotionsResponse(
promotions = listOf(
PromotionsResponse.PromotionDto(name = "dummy", all = null),
),
)
@BeforeEach
fun setUp() {
clearMocks(tangemApi)
}
@Test
fun `GIVEN empty cache WHEN getPromotions THEN fetches and returns response`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response)
val supplier = newSupplier()
// Act
val result = supplier.getPromotions(userWalletId)
// Assert
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) { tangemApi.getPromotions(userWalletId.stringValue, any()) }
}
@Test
fun `GIVEN cached value and no refresh WHEN getPromotions THEN returns cache without api`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response)
val supplier = newSupplier()
supplier.getPromotions(userWalletId)
clearMocks(tangemApi)
// Act
val result = supplier.getPromotions(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(response)
coVerify(exactly = 0) { tangemApi.getPromotions(any(), any()) }
}
@Test
fun `GIVEN cached value WHEN getPromotions forceRefresh THEN hits api again and rebinds value`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } returnsMany listOf(
ApiResponse.Success(response),
ApiResponse.Success(response2),
)
val supplier = newSupplier()
supplier.getPromotions(userWalletId)
// Act
val result = supplier.getPromotions(userWalletId, forceRefresh = true)
// Assert
assertThat(result).isEqualTo(response2)
coVerify(exactly = 2) { tangemApi.getPromotions(any(), any()) }
}
@Test
fun `GIVEN fetch fails and cache present WHEN getPromotions forceRefresh THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response)
val supplier = newSupplier()
supplier.getPromotions(userWalletId)
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("boom")
// Act
val error = runCatching { supplier.getPromotions(userWalletId, forceRefresh = true) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
@Test
fun `GIVEN fetch fails and empty cache WHEN getPromotions THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("boom")
val supplier = newSupplier()
// Act
val error = runCatching { supplier.getPromotions(userWalletId) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
}

View file

@ -70,6 +70,7 @@ internal class ProdApiConfigsManagerTest {
every { appInfoProvider.osVersion } returns "Android 16"
every { appInfoProvider.language } returns Locale.getDefault().toLanguageTag()
every { appInfoProvider.device } returns "${Build.MANUFACTURER} ${Build.MODEL}"
every { appInfoProvider.deviceScale } returns DEVICE_SCALE
manager = ProdApiConfigsManager(apiConfigs = createApiConfigs())
}
@ -170,7 +171,7 @@ internal class ProdApiConfigsManagerTest {
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = when (environment) {
ApiEnvironment.PROD -> "https://authentication.tangem.org/"
ApiEnvironment.PROD -> "https://api.tangem.org/"
else -> "[REDACTED_ENV_URL]"
},
headers = emptyMap(),
@ -298,6 +299,7 @@ internal class ProdApiConfigsManagerTest {
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV },
"X-Device-Scale" to ProviderSuspend { DEVICE_SCALE.toString() },
),
),
)
@ -313,6 +315,7 @@ internal class ProdApiConfigsManagerTest {
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV },
"X-Device-Scale" to ProviderSuspend { DEVICE_SCALE.toString() },
),
),
)
@ -457,6 +460,7 @@ internal class ProdApiConfigsManagerTest {
private companion object {
const val VERSION_NAME = "debug"
const val DEVICE_SCALE = 3f
const val EXPRESS_SESSION_ID = "express_session_id"
const val STAKE_KIT_API_KEY = "stake_kit_api_key"
const val P2P_API_KEY = "p2p_api_key"

View file

@ -0,0 +1,75 @@
package com.tangem.datasource.api.marketing
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.adapter.BigDecimalAdapter
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class MarketingCampaignsResponseTest {
private val moshi = Moshi.Builder().add(BigDecimalAdapter()).build()
private val adapter = moshi.adapter(MarketingCampaignsResponse::class.java)
@Test
fun `GIVEN swap response json WHEN parsed THEN fields mapped`() {
// Arrange
val json = """
{"campaigns":[{"id":12,"type":"swap","priority":1,"minAmount":50,"maxAmount":300,
"providerIds":["provider1"],
"banner":{"uiType":"linked_to_provider","text":"Cashback 4 U","icon":"https://x/star.webp",
"bgColor":"#FF0011","deeplink":"https://tangem.com","dismissible":true}}]}
""".trimIndent()
// Act
val result = adapter.fromJson(json)!!
// Assert
val campaign = result.campaigns.single()
assertThat(campaign.id).isEqualTo(12)
assertThat(campaign.type).isEqualTo("swap")
assertThat(campaign.minAmount).isEqualTo(BigDecimal(50))
assertThat(campaign.providerIds).containsExactly("provider1")
assertThat(campaign.banner.uiType).isEqualTo("linked_to_provider")
assertThat(campaign.banner.isDismissible).isTrue()
assertThat(campaign.tokens).isNull()
}
@Test
fun `GIVEN token_details response WHEN parsed THEN network targets mapped`() {
// Arrange
val json = """
{"campaigns":[{"id":12,"type":"token_details","priority":1,
"tokens":[{"networkId":"ethereum","contractAddress":"0xA0b8"}],
"banner":{"uiType":"standalone","dismissible":false}}]}
""".trimIndent()
// Act
val campaign = adapter.fromJson(json)!!.campaigns.single()
// Assert
val token = campaign.tokens!!.single()
assertThat(token.networkId).isEqualTo("ethereum")
assertThat(token.contractAddress).isEqualTo("0xA0b8")
assertThat(token.id).isNull()
assertThat(campaign.minAmount).isNull()
}
@Test
fun `GIVEN markets response WHEN parsed THEN coingecko ids mapped`() {
// Arrange
val json = """
{"campaigns":[{"id":12,"type":"markets_token","priority":1,
"tokens":[{"id":"1696501400"},{"id":"3296501412"}],
"banner":{"uiType":"standalone","dismissible":true}}]}
""".trimIndent()
// Act
val tokens = adapter.fromJson(json)!!.campaigns.single().tokens!!
// Assert
assertThat(tokens.map { it.id }).containsExactly("1696501400", "3296501412")
assertThat(tokens.all { it.networkId == null }).isTrue()
}
}

View file

@ -15,11 +15,10 @@ internal class ExpressHistoryConverterTest {
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = requireNotNull(item.toEntity())
// THEN
Truth.assertThat(entity.txId).isEqualTo(item.txId)
Truth.assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
Truth.assertThat(entity.providerId).isEqualTo(item.providerId)
Truth.assertThat(entity.fromAddress).isEqualTo(item.fromAddress)
Truth.assertThat(entity.payinAddress).isEqualTo(item.payinAddress)
@ -35,8 +34,7 @@ internal class ExpressHistoryConverterTest {
Truth.assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
Truth.assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
// todo txHistory uncomment
// Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.payTill).isEqualTo(item.payTill)
Truth.assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
}
@ -47,7 +45,7 @@ internal class ExpressHistoryConverterTest {
val item = createExchangeItem(status = "finished")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = requireNotNull(item.toEntity())
// THEN
Truth.assertThat(entity.status).isEqualTo("finished")
@ -59,7 +57,7 @@ internal class ExpressHistoryConverterTest {
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = requireNotNull(item.toEntity())
// THEN
Truth.assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
@ -95,7 +93,7 @@ internal class ExpressHistoryConverterTest {
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = requireNotNull(item.toEntity())
// THEN
Truth.assertThat(entity.payinExtraId).isNull()
@ -112,17 +110,28 @@ internal class ExpressHistoryConverterTest {
Truth.assertThat(entity.to.actualAmount).isNull()
}
@Test
fun `GIVEN exchange item with null fromAddress WHEN toEntity THEN returns null`() {
// GIVEN
val item = createExchangeItem().copy(fromAddress = null)
// WHEN
val entity = item.toEntity()
// THEN
Truth.assertThat(entity).isNull()
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN all transaction fields are mapped`() {
// GIVEN
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = item.toEntity()
// THEN
Truth.assertThat(entity.txId).isEqualTo(item.txId)
Truth.assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
Truth.assertThat(entity.providerId).isEqualTo(item.providerId)
Truth.assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
Truth.assertThat(entity.failReason).isEqualTo(item.failReason)
@ -145,7 +154,7 @@ internal class ExpressHistoryConverterTest {
val item = createOnrampItem(status = "waiting-for-payment")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = item.toEntity()
// THEN
Truth.assertThat(entity.status).isEqualTo("waiting-for-payment")
@ -157,7 +166,7 @@ internal class ExpressHistoryConverterTest {
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = item.toEntity()
// THEN
Truth.assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
@ -180,7 +189,7 @@ internal class ExpressHistoryConverterTest {
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = item.toEntity()
// THEN
Truth.assertThat(entity.failReason).isNull()
@ -223,8 +232,7 @@ internal class ExpressHistoryConverterTest {
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = "2026-06-01T00:00:00Z",
// todo txHistory uncomment
// updatedAt = "2026-06-01T00:05:00Z",
updatedAt = "2026-06-01T00:05:00Z",
payTill = payTill,
averageDuration = averageDuration,
fromContractAddress = "0xfromContract",
@ -256,8 +264,7 @@ internal class ExpressHistoryConverterTest {
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = "2026-06-01T00:00:00Z",
// todo txHistory uncomment
// updatedAt = "2026-06-01T00:05:00Z",
updatedAt = "2026-06-01T00:05:00Z",
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,
@ -269,8 +276,4 @@ internal class ExpressHistoryConverterTest {
paymentMethod = "card",
countryCode = "US",
)
private companion object {
const val OWNER_ADDRESS = "0xowner"
}
}

View file

@ -0,0 +1,88 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.emptyPreferences
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.pay.TangemPayReissueCardFee
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for [DefaultTangemPayReissueCardStore], focused on the reissue order-id lifecycle.
*
* Uses a real [AppPreferencesStore] backed by an in-memory [MockStateDataStore] so the preferences
* round-trip (store / read / remove) is exercised end-to-end. The remove path backs the [REDACTED_TASK_KEY] fix:
* a terminal reissue order must be forgotten so the payment-account refresh stops re-polling
* `GET /order/{id}` for it.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultTangemPayReissueCardStoreTest {
private val dataStore = MockStateDataStore(default = emptyPreferences())
private val prefs = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = dataStore,
)
private val feeStore: RuntimeDataStore<TangemPayReissueCardFee> = mockk(relaxed = true)
private val store = DefaultTangemPayReissueCardStore(feeStore = feeStore, prefs = prefs)
@BeforeEach
fun resetStore() {
runBlocking { dataStore.updateData { emptyPreferences() } }
}
@Test
fun `GIVEN order id stored WHEN getOrderId THEN returns stored id`() = runTest {
// Arrange
store.storeReissueOrderId(CARD_ID, ORDER_ID)
// Act
val result = store.getOrderId(CARD_ID)
// Assert
assertThat(result).isEqualTo(ORDER_ID)
}
@Test
fun `GIVEN order id stored WHEN removeReissueOrderId THEN order id is cleared`() = runTest {
// Arrange
store.storeReissueOrderId(CARD_ID, ORDER_ID)
// Act
store.removeReissueOrderId(CARD_ID)
// Assert
assertThat(store.getOrderId(CARD_ID)).isNull()
}
@Test
fun `GIVEN clearing one card WHEN another card has an order THEN the other is untouched`() = runTest {
// Arrange
store.storeReissueOrderId(CARD_ID, ORDER_ID)
store.storeReissueOrderId(OTHER_CARD_ID, OTHER_ORDER_ID)
// Act
store.removeReissueOrderId(CARD_ID)
// Assert
assertThat(store.getOrderId(CARD_ID)).isNull()
assertThat(store.getOrderId(OTHER_CARD_ID)).isEqualTo(OTHER_ORDER_ID)
}
private companion object {
const val CARD_ID = "card-1"
const val OTHER_CARD_ID = "card-2"
const val ORDER_ID = "reissue-order-1"
const val OTHER_ORDER_ID = "reissue-order-2"
}
}

View file

@ -0,0 +1,119 @@
package com.tangem.datasource.local.visa
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.test.core.datastore.MockStateDataStore
import kotlinx.coroutines.test.runTest
import org.joda.time.DateTime
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.util.Currency
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultTangemPayTxHistoryItemsStoreTest {
private lateinit var store: DefaultTangemPayTxHistoryItemsStore
@BeforeEach
fun setup() {
store = DefaultTangemPayTxHistoryItemsStore(
dataStore = MockStateDataStore(default = emptyMap()),
toDMConverter = TangemPayTxHistoryItemToDMConverter(),
toDomainConverter = TangemPayTxHistoryItemToDomainConverter(),
)
}
@Test
fun `GIVEN empty store WHEN getSyncOrNull THEN returns null`() = runTest {
val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_1)
assertThat(result).isNull()
}
@Test
fun `GIVEN stored page WHEN getSyncOrNull with same key and cursor THEN returns page`() = runTest {
// Arrange
val page = listOf(payment("1"), payment("2"))
store.store(key = WALLET_A, cursor = CURSOR_1, value = page)
// Act
val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_1)
// Assert
assertThat(result).isEqualTo(page)
}
@Test
fun `GIVEN page stored under one cursor WHEN getSyncOrNull with another cursor THEN returns null`() = runTest {
// Arrange
store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("1")))
// Act
val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_2)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN two cursors stored for one wallet WHEN getSyncOrNull THEN both pages are kept`() = runTest {
// Arrange
val page1 = listOf(payment("1"))
val page2 = listOf(payment("2"))
store.store(key = WALLET_A, cursor = CURSOR_1, value = page1)
store.store(key = WALLET_A, cursor = CURSOR_2, value = page2)
// Assert
assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isEqualTo(page1)
assertThat(store.getSyncOrNull(WALLET_A, CURSOR_2)).isEqualTo(page2)
}
@Test
fun `GIVEN entries for two wallets WHEN remove one wallet THEN only that wallet is cleared`() = runTest {
// Arrange
store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("a")))
store.store(key = WALLET_B, cursor = CURSOR_1, value = listOf(payment("b")))
// Act
store.remove(WALLET_A)
// Assert
assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isNull()
assertThat(store.getSyncOrNull(WALLET_B, CURSOR_1)).isEqualTo(listOf(payment("b")))
}
@Test
fun `GIVEN entries for two wallets WHEN remove both in one call THEN both are cleared`() = runTest {
// Arrange
store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("a")))
store.store(key = WALLET_B, cursor = CURSOR_1, value = listOf(payment("b")))
// Act
store.remove(listOf(WALLET_A, WALLET_B))
// Assert
assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isNull()
assertThat(store.getSyncOrNull(WALLET_B, CURSOR_1)).isNull()
}
private fun payment(id: String) = TangemPayTxHistoryItem.Payment(
id = id,
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("1.00"),
currency = Currency.getInstance("USD"),
transactionHash = null,
)
private companion object {
const val WALLET_A = "wallet-a"
const val WALLET_B = "wallet-b"
const val CURSOR_1 = "cursor-1"
const val CURSOR_2 = "cursor-2"
const val DATE_MILLIS = 1_700_000_000_000L
}
}

View file

@ -0,0 +1,122 @@
package com.tangem.datasource.local.visa
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import org.joda.time.DateTime
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.util.Currency
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayTxHistoryStoreSerializationTest {
private val json = KotlinxDataStoreSerializer.jsonBuilder { classDiscriminator = "__type" }
private val serializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = ListSerializer(TangemPayTxHistoryItemDM.serializer()),
),
)
@Test
fun `GIVEN all tx history item types WHEN serialized and deserialized THEN value is preserved`() {
// Arrange
val original: Map<String, Map<String, List<TangemPayTxHistoryItemDM>>> = mapOf(
"wallet-1" to mapOf(
"initial_cursor_key" to listOf(spend(), payment(), fee(), collateral()),
),
)
// Act
val restored = json.decodeFromString(serializer, json.encodeToString(serializer, original))
// Assert
assertThat(restored).isEqualTo(original)
}
@Test
fun `GIVEN Collateral with type field WHEN serialized and deserialized THEN discriminator does not clash`() {
// Arrange
val original: Map<String, Map<String, List<TangemPayTxHistoryItemDM>>> = mapOf(
"wallet-1" to mapOf("cursor" to listOf(collateral())),
)
// Act
val restored = json.decodeFromString(serializer, json.encodeToString(serializer, original))
// Assert
assertThat(restored).isEqualTo(original)
}
@Test
fun `GIVEN tx history item WHEN serialized THEN uses stable SerialName under non-clashing discriminator`() {
// Arrange
val original: Map<String, Map<String, List<TangemPayTxHistoryItemDM>>> = mapOf(
"wallet-1" to mapOf("cursor" to listOf(collateral())),
)
// Act
val encoded = json.encodeToString(serializer, original)
// Assert
assertThat(encoded).contains("\"__type\":\"collateral\"")
}
private fun spend() = TangemPayTxHistoryItemDM.Spend(
id = "spend-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("12.34"),
currency = Currency.getInstance("USD"),
authorizedAmount = BigDecimal("12.34"),
localAmount = BigDecimal("11.00"),
localCurrency = Currency.getInstance("EUR"),
enrichedMerchantName = "Coffee Co",
merchantName = "COFFEE CO",
enrichedMerchantCategory = "Food",
merchantCategoryCode = "5814",
merchantCategory = "restaurants",
status = TangemPayTxHistoryItemDM.Status.COMPLETED,
enrichedMerchantIconUrl = "https://example.com/icon.png",
declinedReason = null,
)
private fun payment() = TangemPayTxHistoryItemDM.Payment(
id = "payment-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("100.00"),
currency = Currency.getInstance("USD"),
transactionHash = "0xabc",
)
private fun fee() = TangemPayTxHistoryItemDM.Fee(
id = "fee-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("0.50"),
currency = Currency.getInstance("USD"),
description = "network fee",
)
private fun collateral() = TangemPayTxHistoryItemDM.Collateral(
id = "collateral-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("250.00"),
currency = Currency.getInstance("USD"),
transactionHash = "0xdef",
type = TangemPayTxHistoryItemDM.Type.Deposit,
)
private companion object {
const val DATE_MILLIS = 1_700_000_000_000L
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.datasource.local.visa.entity
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.test.core.ProvideTestModels
import org.joda.time.DateTime
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import java.math.BigDecimal
import java.util.Currency
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayTxHistoryItemDMConverterTest {
private val toDMConverter = TangemPayTxHistoryItemToDMConverter()
private val toDomainConverter = TangemPayTxHistoryItemToDomainConverter()
@ParameterizedTest
@ProvideTestModels
fun `GIVEN every item subtype WHEN converted to DM and back THEN all fields preserved`(
item: TangemPayTxHistoryItem,
) {
assertRoundTrip(item)
}
@ParameterizedTest
@EnumSource(TangemPayTxHistoryItem.Status::class)
fun `GIVEN every Spend status WHEN converted to DM and back THEN status preserved`(
status: TangemPayTxHistoryItem.Status,
) {
assertRoundTrip(spend().copy(status = status))
}
@ParameterizedTest
@EnumSource(TangemPayTxHistoryItem.Type::class)
fun `GIVEN every Collateral type WHEN converted to DM and back THEN type preserved`(
type: TangemPayTxHistoryItem.Type,
) {
assertRoundTrip(collateral().copy(type = type))
}
private fun assertRoundTrip(item: TangemPayTxHistoryItem) {
// Act
val restored = toDomainConverter.convert(toDMConverter.convert(item))
// Assert
assertThat(restored).isEqualTo(item)
}
private fun provideTestModels() = listOf(spend(), payment(), fee(), collateral())
private fun spend() = TangemPayTxHistoryItem.Spend(
id = "spend-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("12.34"),
currency = Currency.getInstance("USD"),
authorizedAmount = BigDecimal("12.34"),
localAmount = BigDecimal("11.00"),
localCurrency = Currency.getInstance("EUR"),
enrichedMerchantName = "Coffee Co",
merchantName = "COFFEE CO",
enrichedMerchantCategory = "Food",
merchantCategoryCode = "5814",
merchantCategory = "restaurants",
status = TangemPayTxHistoryItem.Status.COMPLETED,
enrichedMerchantIconUrl = "https://example.com/icon.png",
declinedReason = null,
)
private fun payment() = TangemPayTxHistoryItem.Payment(
id = "payment-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("100.00"),
currency = Currency.getInstance("USD"),
transactionHash = "0xabc",
)
private fun fee() = TangemPayTxHistoryItem.Fee(
id = "fee-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("0.50"),
currency = Currency.getInstance("USD"),
description = "network fee",
)
private fun collateral() = TangemPayTxHistoryItem.Collateral(
id = "collateral-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("250.00"),
currency = Currency.getInstance("USD"),
transactionHash = "0xdef",
type = TangemPayTxHistoryItem.Type.Deposit,
)
private companion object {
const val DATE_MILLIS = 1_700_000_000_000L
}
}

View file

@ -10,12 +10,25 @@ android {
}
dependencies {
api(projects.core.utils)
api(deps.decompose)
api(deps.androidx.appCompat)
implementation(deps.kotlin.coroutines)
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region AndroidX
api(deps.androidx.appCompat)
// endregion
// region Other libraries
api(deps.decompose)
// endregion
// region Core modules
api(projects.core.utils)
// endregion
}

View file

@ -9,8 +9,13 @@ android {
}
dependencies {
api(projects.core.error)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
// region Tangem
api(tangemDeps.blockchain)
api(tangemDeps.card.core)
// endregion
// region Core modules
api(projects.core.error)
// endregion
}

View file

@ -1,8 +1,6 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
@ -11,10 +9,16 @@ android {
}
dependencies {
// region Core modules
implementation(projects.core.utils)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
implementation(deps.material)
// region AndroidX
implementation(deps.androidx.core)
// endregion
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.navigation.url
interface AppStoreOpener {
fun openStorePage()
}

View file

@ -0,0 +1,50 @@
package com.tangem.core.navigation.url
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.tangem.utils.buildConfig.AppConfigurationProvider
import com.tangem.utils.logging.TangemLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class DefaultAppStoreOpener @Inject constructor(
@ApplicationContext private val context: Context,
private val appConfigurationProvider: AppConfigurationProvider,
) : AppStoreOpener {
override fun openStorePage() {
val storeUri: String
val webUrl: String
if (appConfigurationProvider.isHuawei()) {
storeUri = "$HUAWEI_STORE_SCHEME$STORE_PACKAGE_NAME"
webUrl = "$HUAWEI_WEB_URL$STORE_PACKAGE_NAME"
} else {
storeUri = "$GOOGLE_STORE_SCHEME$STORE_PACKAGE_NAME"
webUrl = "$GOOGLE_WEB_URL$STORE_PACKAGE_NAME"
}
openUri(storeUri) || openUri(webUrl)
}
private fun openUri(uri: String): Boolean {
return try {
context.startActivity(
Intent(Intent.ACTION_VIEW, uri.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
true
} catch (e: ActivityNotFoundException) {
TangemLogger.e("Unable to open store uri: $uri", e)
false
}
}
private companion object {
const val STORE_PACKAGE_NAME = "com.tangem.wallet"
const val GOOGLE_STORE_SCHEME = "market://details?id="
const val GOOGLE_WEB_URL = "https://play.google.com/store/apps/details?id="
const val HUAWEI_STORE_SCHEME = "appmarket://details?id="
const val HUAWEI_WEB_URL = "https://appgallery.huawei.com/app/"
}
}

View file

@ -4,7 +4,7 @@ plugins {
}
dependencies {
// region Coroutines
implementation(deps.kotlin.coroutines)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
}

View file

@ -10,12 +10,17 @@ android {
dependencies {
implementation(projects.core.utils)
// region AndroidX
api(deps.androidx.annotation)
// endregion
// region Firebase libraries
// region Firebase
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.analytics)
implementation(deps.firebase.crashlytics)
// endregion
// region Core modules
implementation(projects.core.utils)
// endregion
}

View file

@ -256,7 +256,7 @@
<string name="common_action_failed">%s falló</string>
<string name="common_activate">Activar</string>
<string name="common_add">Agregar</string>
<string name="common_add_funds">Agregar fondos</string>
<string name="common_add_funds">Depositar</string>
<string name="common_add_to_portfolio">Añadir al portafolio</string>
<string name="common_add_token">Agregar token</string>
<string name="common_add_tokens">Añada tokens</string>
@ -347,8 +347,9 @@
<string name="common_from">De</string>
<string name="common_from_wallet_name">De %s</string>
<string name="common_generate_addresses">Sincronizar direcciones</string>
<string name="common_get">Comprar</string>
<string name="common_get_started">Comenzar</string>
<string name="common_get_token">Obtener token</string>
<string name="common_get_token">Comprar token</string>
<string name="common_go_to_provider">Ir al proveedor</string>
<string name="common_go_to_token">Ir al token</string>
<string name="common_got_it">Entendido</string>
@ -1241,6 +1242,8 @@
<string name="onramp_title_available_from">Disponible desde</string>
<string name="onramp_title_available_up_to">Disponible hasta</string>
<string name="onramp_title_you_get">Obtiene</string>
<string name="onramp_token_is_not_supported_banner_subtitle">Este token no está soportado. Por favor, elija otro token para comprar.</string>
<string name="onramp_token_is_not_supported_banner_title">%s no está soportado</string>
<string name="onramp_tos_external_providers">El servicio es proporcionado por un proveedor externo. \nTangem no es responsable.</string>
<string name="onramp_transaction_status_footer_text">Puede comprobar el estado de la transacción desde la página detallada del token</string>
<string name="onramp_up_to_rate">Hasta</string>
@ -2505,7 +2508,7 @@
<string name="yield_module_approve_sheet_fee_note">Se le descontará la comisión y se volverán a suministrar sus activos.</string>
<string name="yield_module_approve_sheet_subtitle">Para seguir generando rendimiento, se requiere aprobación.</string>
<string name="yield_module_approve_sheet_title">Confirmar aprobación</string>
<string name="yield_module_average_apy">APY promedio %1$s%%</string>
<string name="yield_module_average_apy">APY actual %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Sus fondos se suministran actualmente al protocolo Aave, pero puede gestionarlos en cualquier momento.</string>
<string name="yield_module_balance_info_sheet_title">Su %s está depositado en Aave</string>
<string name="yield_module_chart_loading_error">No se puede cargar el gráfico...</string>

View file

@ -2011,6 +2011,7 @@
<string name="tangempay_reissue_card_insufficient_funds_subtitle">Внесите USDC на счёт, чтобы покрыть комиссию</string>
<string name="tangempay_reissue_card_insufficient_funds_title">Невозможно покрыть комиссию</string>
<string name="tangempay_reissue_card_title">Перевыпустить карту?</string>
<string name="tangempay_remove_account">Удалить аккаунт</string>
<string name="tangempay_service_unavailable_description">Мы устраняем техническую проблему. Пожалуйста, попробуйте позже.</string>
<string name="tangempay_service_unavailable_title">Сервис временно недоступен</string>
<string name="tangempay_service_unreachable_try_later">Не можем показать данные карты, но оплаты продолжают работать.</string>
@ -2520,7 +2521,7 @@
<string name="yield_module_approve_sheet_fee_note">Комиссия будет списана, и ваши активы снова начнут приносить доход.</string>
<string name="yield_module_approve_sheet_subtitle">Чтобы продолжить зарабатывать, нужно выдать разрешение.</string>
<string name="yield_module_approve_sheet_title">Подтвердить разрешение</string>
<string name="yield_module_average_apy">Средний APY %1$s%%</string>
<string name="yield_module_average_apy">Текущий APY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Ваши средства в данный момент размещены в протоколе Aave, но вы можете воспользоваться ими в любое время.</string>
<string name="yield_module_balance_info_sheet_title">Ваш %s внесён в Aave</string>
<string name="yield_module_chart_loading_error">Невозможно загрузить график</string>

View file

@ -928,6 +928,7 @@
<string name="markets_portfolio_block_subtitle">In your portfolio</string>
<string name="markets_portfolio_block_title">Your portfolio</string>
<string name="markets_portfolio_block_token_unsupported">**Token not supported**. This token is currently not supported in the wallet</string>
<string name="markets_portfolio_eligible_block_title">Other eligible tokens</string>
<string name="markets_pulse_common_title">Market Pulse</string>
<string name="markets_quick_actions">Quick actions</string>
<string name="markets_search_clear_all_hints">Clear all</string>
@ -2624,7 +2625,7 @@
<string name="yield_module_approve_sheet_fee_note">The fee will be deducted, and your assets will be resupplied.</string>
<string name="yield_module_approve_sheet_subtitle">To continue generating yield, approval is required.</string>
<string name="yield_module_approve_sheet_title">Confirm approval</string>
<string name="yield_module_average_apy">Average APY %1$s%%</string>
<string name="yield_module_average_apy">Current APY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Your funds are currently supplied to the Aave protocol, but you can manage them at any time.</string>
<string name="yield_module_balance_info_sheet_title">Your %s is supplied to Aave</string>
<string name="yield_module_chart_loading_error">Unable to load chart...</string>

View file

@ -1,3 +1,4 @@
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
plugins {
@ -61,15 +62,15 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
require(actual == expected) {
"Design tokens are out of date!\n" +
" ds-tokens hash: $actual\n" +
" generated hash: $expected\n" +
" computed from ds-tokens: $actual\n" +
" committed .tokens-hash: $expected\n" +
"Run the token generator: cd core/ui/token-gen && npm run build"
}
stampFile.get().asFile.writeText(actual)
}
private fun hashTreeHex(root: java.io.File, extension: String): String {
private fun hashTreeHex(root: File, extension: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val files = root.walkTopDown()
.filter { it.isFile && it.extension == extension }
@ -79,12 +80,27 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
for (file in files) {
digest.update(file.relativeTo(root).invariantSeparatorsPath.toByteArray())
digest.update(nul)
digest.update(file.readBytes())
digest.update(file.readBytes().stripCr())
digest.update(nul)
}
return digest.digest()
.joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') }
}
/**
* Strips CR (0x0D) bytes so the token hash ignores CRLF vs LF line endings. The ds-tokens
* submodule is not covered by this repo's .gitattributes, so its .json/.svg sources may be
* checked out with CRLF on some platforms; without this the verification is non-deterministic.
* Removes lone CRs too — safe for UTF-8 sources, where 0x0D never appears inside a
* multi-byte sequence. Must stay byte-for-byte identical to stripCr() in token-gen/hash-util.mjs.
*/
private fun ByteArray.stripCr(): ByteArray {
val cr: Byte = 0x0D
if (none { it == cr }) return this
val out = ByteArrayOutputStream(size)
for (b in this) if (b != cr) out.write(b.toInt())
return out.toByteArray()
}
}
android {
namespace = "com.tangem.core.ui"
@ -109,51 +125,53 @@ tasks.named("preBuild") {
}
dependencies {
/** Project - Domain */
implementation(projects.domain.appTheme.models)
implementation(projects.domain.express.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
/** Project - Core */
implementation(projects.core.res)
implementation(projects.core.utils)
api(projects.core.decompose)
implementation(projects.core.error)
// region DI
implementation(deps.hilt.android)
// endregion
/** AndroidX libraries */
implementation(deps.androidx.fragment.ktx)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.immutable.collections)
api(deps.kotlin.serialization.core)
// endregion
// region Compose
implementation(deps.compose.accompanist.permission)
api(deps.compose.accompanist.systemUiController)
api(deps.compose.coil)
implementation(deps.compose.constraintLayout)
api(deps.compose.foundation)
api(deps.compose.material3)
api(deps.compose.paging)
api(deps.compose.reorderable)
api(deps.compose.shimmer)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
// endregion
// region AndroidX
api(deps.androidx.activity)
implementation(deps.androidx.activity.compose)
implementation(deps.androidx.annotation)
implementation(deps.androidx.appCompat)
implementation(deps.androidx.core)
implementation(deps.androidx.core.ktx)
implementation(deps.androidx.paging.runtime)
implementation(deps.lifecycle.runtime.ktx)
implementation(deps.androidx.palette)
api(deps.androidx.palette)
implementation(deps.androidx.savedState)
implementation(deps.androidx.windowManager) {
exclude(
deps.kotlin.coroutines.android.get().module.group,
deps.kotlin.coroutines.android.get().module.name
deps.kotlin.coroutines.android.get().module.name,
)
}
api(deps.lifecycle.compose)
api(deps.lifecycle.runtime.ktx)
// endregion
/** Compose */
implementation(deps.compose.constraintLayout)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.paging)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
implementation(deps.compose.coil)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
api(deps.compose.reorderable)
/** Other libraries */
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.accompanist.permission)
implementation(deps.material)
implementation(deps.compose.shimmer)
implementation(deps.kotlin.immutable.collections)
implementation(deps.zxing.qrCore)
api(deps.jodatime)
implementation(deps.markdown)
// region Other libraries
api(deps.arrow.core)
api(deps.haze) {
exclude(module = "activity-compose")
exclude(module = "activity")
@ -164,9 +182,28 @@ dependencies {
exclude(module = "activity")
exclude(module = "activity-ktx")
}
api(deps.jodatime)
api(deps.markdown)
implementation(deps.material)
implementation(deps.zxing.qrCore)
// endregion
/** Tests */
// region Core modules
api(projects.core.decompose)
api(projects.core.error)
implementation(projects.core.res)
implementation(projects.core.utils)
// endregion
// region Domain models
api(projects.domain.appTheme.models)
api(projects.domain.express.models)
api(projects.domain.models)
// endregion
// region Tests
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
// endregion
}

@ -1 +1 @@
Subproject commit 42aac70c1d2d0d636470fa476703cab353010bba
Subproject commit bfd2053b1f6d1ffc051d537e41e6bc386a34ad5f

View file

@ -32,7 +32,15 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
enum class AccountIconSize {
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall
Default,
Large,
Medium,
Small,
ExtraSmall,
RedesignedDefault,
RedesignExtraSmall,
ContactLarge,
ContactDefault,
}
/**
@ -132,6 +140,8 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1
AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28
AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11
AccountIconSize.ContactLarge -> TangemTheme.typography3.heading.medium
AccountIconSize.ContactDefault -> TangemTheme.typography3.body.medium
}
val textSize by animateFloatAsState(
@ -166,6 +176,8 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) {
AccountIconSize.ExtraSmall -> 8.dp
AccountIconSize.RedesignedDefault -> 20.dp
AccountIconSize.RedesignExtraSmall -> 8.dp
AccountIconSize.ContactLarge -> 32.dp
AccountIconSize.ContactDefault -> 20.dp
}
fun AccountIconSize.toBoxSize(): Dp = when (this) {
@ -176,6 +188,8 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) {
AccountIconSize.ExtraSmall -> 14.dp
AccountIconSize.RedesignedDefault -> 40.dp
AccountIconSize.RedesignExtraSmall -> 16.dp
AccountIconSize.ContactLarge -> 80.dp
AccountIconSize.ContactDefault -> 40.dp
}
private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
@ -186,6 +200,8 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
AccountIconSize.ExtraSmall -> 4.dp
AccountIconSize.RedesignedDefault -> 12.dp
AccountIconSize.RedesignExtraSmall -> 6.dp
AccountIconSize.ContactLarge -> 80.dp
AccountIconSize.ContactDefault -> 100.dp
}
@Preview(showBackground = true)
@ -228,7 +244,9 @@ private fun Sample() {
AccountIconSize.Small -> AccountIconSize.ExtraSmall
AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault
AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall
AccountIconSize.RedesignExtraSmall -> AccountIconSize.Default
AccountIconSize.RedesignExtraSmall -> AccountIconSize.ContactLarge
AccountIconSize.ContactLarge -> AccountIconSize.ContactDefault
AccountIconSize.ContactDefault -> AccountIconSize.Default
}
}) { Text("Change") }

View file

@ -36,12 +36,10 @@ import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggab
import com.tangem.core.ui.components.sheetscaffold.TangemSheetState
import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue
import com.tangem.core.ui.components.sheetscaffold.rememberSheetState
import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.*
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.utils.WindowInsetsZero
import dev.chrisbanes.haze.rememberHazeState
/**
* Extra bottom inset that scrollable content under a [BasicBottomSheet] should reserve so it
@ -235,46 +233,48 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicBottomSheet(
}
val bsContent: @Composable ColumnScope.() -> Unit = {
val contentModifier = when (type) {
Default -> Modifier
.clip(
RoundedCornerShape(
topStart = TangemTheme.dimens2.x8,
topEnd = TangemTheme.dimens2.x8,
),
)
Modal -> Modifier
.padding(
start = TangemTheme.dimens2.x2,
end = TangemTheme.dimens2.x2,
bottom = bottomBarHeight,
)
.clip(RoundedCornerShape(TangemTheme.dimens2.x8))
}
Column(
modifier = contentModifier
.background(containerColor)
.heightIn(max = maxHeight)
.testTag(BaseBottomSheetTestTags.CONTAINER),
) {
Box(modifier = Modifier.fillMaxWidth()) {
title(model)
}
Box(modifier = Modifier.fillMaxWidth()) {
if (footer != null) {
FooterOverlay(
measuredFooterHeight = footerHeightDp,
onMeasureFooter = { newHeight ->
if (newHeight != footerHeightDp) {
footerHeightDp = newHeight
}
},
footer = { footer(model) },
content = { content(model) },
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
val contentModifier = when (type) {
Default -> Modifier
.clip(
RoundedCornerShape(
topStart = TangemTheme.dimens2.x8,
topEnd = TangemTheme.dimens2.x8,
),
)
} else {
content(model)
Modal -> Modifier
.padding(
start = TangemTheme.dimens2.x2,
end = TangemTheme.dimens2.x2,
bottom = bottomBarHeight,
)
.clip(RoundedCornerShape(TangemTheme.dimens2.x8))
}
Column(
modifier = contentModifier
.background(containerColor)
.heightIn(max = maxHeight)
.testTag(BaseBottomSheetTestTags.CONTAINER),
) {
Box(modifier = Modifier.fillMaxWidth()) {
title(model)
}
Box(modifier = Modifier.fillMaxWidth()) {
if (footer != null) {
FooterOverlay(
measuredFooterHeight = footerHeightDp,
onMeasureFooter = { newHeight ->
if (newHeight != footerHeightDp) {
footerHeightDp = newHeight
}
},
footer = { footer(model) },
content = { content(model) },
)
} else {
content(model)
}
}
}
}

View file

@ -49,11 +49,11 @@ data class MessageBottomSheetUM(
var backgroundType: BackgroundType = BackgroundType.Unspecified,
) : Element {
enum class Type {
Unspecified, Accent, Informative, Attention, Warning,
Unspecified, Accent, Informative, Attention, Warning, Success,
}
enum class BackgroundType {
Unspecified, SameAsTint, Accent, Informative, Attention, Warning,
Unspecified, SameAsTint, Accent, Informative, Attention, Warning, Success,
}
}

View file

@ -214,6 +214,7 @@ private fun BottomSheetVector(vector: MessageBottomSheetUM.Vector, modifier: Mod
MessageBottomSheetUM.Vector.Type.Informative -> TangemTheme.colors3.icon.status.info
MessageBottomSheetUM.Vector.Type.Attention -> TangemTheme.colors3.icon.status.warning
MessageBottomSheetUM.Vector.Type.Warning -> TangemTheme.colors3.icon.status.error
MessageBottomSheetUM.Vector.Type.Success -> TangemTheme.colors3.icon.status.success
}
val backgroundColor = when (vector.backgroundType) {
@ -223,6 +224,7 @@ private fun BottomSheetVector(vector: MessageBottomSheetUM.Vector, modifier: Mod
MessageBottomSheetUM.Vector.BackgroundType.Informative -> TangemTheme.colors3.bg.status.infoSubtle
MessageBottomSheetUM.Vector.BackgroundType.Attention -> TangemTheme.colors3.bg.status.warningSubtle
MessageBottomSheetUM.Vector.BackgroundType.Warning -> TangemTheme.colors3.bg.status.errorSubtle
MessageBottomSheetUM.Vector.BackgroundType.Success -> TangemTheme.colors3.bg.status.successSubtle
}
Box(

Some files were not shown because too many files have changed in this diff Show more