Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-01 21:31:52 +04:00
commit 3aaf9c5b0a
2074 changed files with 88068 additions and 12659 deletions

View file

@ -3,11 +3,6 @@ plugins {
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** DI */
@ -32,5 +27,4 @@ dependencies {
/** Tests */
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -4,7 +4,6 @@
<CurrentIssues>
<ID>MultilineLambdaItParameter:TechAnalyticsEvent.kt$TechAnalyticsEvent.KeyboardIdentifier${ put("Package", it) put("GPUrl", "https://play.google.com/store/apps/details?id=$packageName") }</ID>
<ID>UseEmptyCounterpart:AnalyticsEvent.kt$AnalyticsEvent$mapOf()</ID>
<ID>UseEmptyCounterpart:Basic.kt$Basic$mapOf()</ID>
<ID>UseEmptyCounterpart:ExceptionAnalyticsEvent.kt$ExceptionAnalyticsEvent$mapOf()</ID>
<ID>UseEmptyCounterpart:MainScreenAnalyticsEvent.kt$MainScreenAnalyticsEvent$mapOf()</ID>
<ID>UseEmptyCounterpart:OnboardingAnalyticsEvent.kt$OnboardingAnalyticsEvent$mapOf()</ID>

View file

@ -71,6 +71,7 @@ sealed class AnalyticsParam {
@Serializable
enum class ScreensSources(val value: String) {
Settings("Settings"),
CardSettings("Card Settings"),
Main("Main"),
SignIn("Sign In"),
Send("Send"),
@ -328,6 +329,7 @@ sealed class AnalyticsParam {
const val WALLET_TYPE = "Wallet Type"
const val BACKUPED = "Backuped"
const val MEMO = "Memo"
const val VALUE = "Value"
}
}

View file

@ -62,11 +62,6 @@ tasks.named("preBuild") {
tasks.withType<Detekt>().configureEach {
exclude { it.file.absolutePath.contains("/build/generated/") }
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** DI */
implementation(deps.hilt.android)
@ -85,5 +80,4 @@ dependencies {
implementation(projects.core.utils)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -17,7 +17,7 @@
},
{
"name": "APP_REDESIGN_ENABLED",
"version": "undefined"
"version": "6.0"
},
{
"name": "GASLESS_APPROVAL_ENABLED",
@ -55,17 +55,21 @@
"name": "WALLET_CONNECT_BITCOIN_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1326_YIELD_MODE_SWAP_ENABLED",
"version": "6.0"
},
{
"name": "ADDRESS_SYNC_ENABLED",
"version": "undefined"
},
{
"name": "AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED",
"version": "undefined"
"version": "6.0"
},
{
"name": "SWAP_INTEGRATED_APPROVE",
"version": "undefined"
"name": "AND_15120_SWAP_INTEGRATED_APPROVE",
"version": "6.0"
},
{
"name": "SWAP_AB_ENABLED",
@ -75,10 +79,18 @@
"name": "TWI_1403_PUSH_NOTIFICATION_SETTINGS_ENABLED",
"version": "undefined"
},
{
"name": "AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED",
"version": "6.0"
},
{
"name": "AND_15310_ADD_FUNDS_STAGE1",
"version": "5.39"
},
{
"name": "TWI_1377_MANAGE_FUNDS",
"version": "6.0"
},
{
"name": "AND_15009_SWAP_PROVIDER_FILTER_ENABLED",
"version": "5.39"
@ -106,5 +118,53 @@
{
"name": "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED",
"version": "5.39"
},
{
"name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED",
"version": "undefined"
},
{
"name": "AND_15482_SURVEYSPARROW_ENABLED",
"version": "undefined"
},
{
"name": "AND_15258_QUICK_TOP_UP_ENABLED",
"version": "6.0"
},
{
"name": "AND_15368_VISA_PAY_REDESIGN",
"version": "6.0"
},
{
"name": "AND_15364_VISA_PAY_CARD_CLOSE",
"version": "6.0"
},
{
"name": "AND_15741_VISA_PAY_REMOVE_ACCOUNT",
"version": "undefined"
},
{
"name": "TWI_83_ADDRESS_BOOK_ENABLED",
"version": "undefined"
},
{
"name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED",
"version": "6.0"
},
{
"name": "AND_15235_VISA_MULTIPLE_CARDS",
"version": "6.0"
},
{
"name": "AND_15715_SWAP_BEST_DEX_RATE_ENABLED",
"version": "6.0"
},
{
"name": "AND_15767_NEW_TX_HISTORY_ENABLED",
"version": "undefined"
},
{
"name": "AND_14829_WARNINGS_REFACTORING_ENABLED",
"version": "undefined"
}
]

View file

@ -0,0 +1,14 @@
package com.tangem.core.configtoggle.feature
/**
* Feature toggle information exposed by [MutableFeatureTogglesManager].
*
* @property name raw toggle name
* @property version release version from local config ("undefined" for permanently disabled toggles)
* @property isEnabled current toggle state (may differ from default if overridden locally)
*/
data class FeatureToggleInfo(
val name: String,
val version: String,
val isEnabled: Boolean,
)

View file

@ -2,6 +2,9 @@ package com.tangem.core.configtoggle.feature
import com.tangem.core.configtoggle.FeatureToggles
/** Version value marking a feature toggle that has no planned release (permanently disabled). */
const val DISABLED_FEATURE_TOGGLE_VERSION = "undefined"
/**
* Component for getting information about the availability of feature toggles
*

View file

@ -10,8 +10,8 @@ interface MutableFeatureTogglesManager : FeatureTogglesManager {
/** Check if the current state of the feature toggles matches the local config state. */
fun isMatchLocalConfig(): Boolean
/** Get feature toggles */
fun getFeatureToggles(): Map<String, Boolean>
/** Get feature toggles with version info */
fun getFeatureToggles(): List<FeatureToggleInfo>
/** Change availability [isEnabled] of toggle with name [name] */
suspend fun changeToggle(name: String, isEnabled: Boolean)

View file

@ -2,14 +2,16 @@ package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureToggleInfo
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.utils.toTableString
import com.tangem.core.configtoggle.version.VersionProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import kotlin.properties.Delegates
/**
* Feature toggles manager implementation in dev or mocked build
@ -24,54 +26,62 @@ internal class DevFeatureTogglesManager(
private val featureTogglesLocalStorage: LocalTogglesStorage,
) : MutableFeatureTogglesManager {
private val fileFeatureTogglesMap: Map<String, Boolean> = getFileFeatureToggles()
private val fileFeatureToggles: List<FeatureToggleInfo> = buildFileFeatureToggles()
@Suppress("DoubleMutabilityForCollection")
private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
private val currentToggles: MutableStateFlow<List<FeatureToggleInfo>> = MutableStateFlow(buildInitialToggles())
init {
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() }
featureTogglesMap = fileFeatureTogglesMap
.mapValues { resultToggle ->
savedFeatureToggles[resultToggle.key] ?: resultToggle.value
}
.toMutableMap()
}
override fun isFeatureEnabled(toggle: FeatureToggles): Boolean = featureTogglesMap[toggle.rawName] == true
override fun isFeatureEnabled(toggle: FeatureToggles): Boolean =
currentToggles.value.any { it.name == toggle.rawName && it.isEnabled }
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun isFeatureEnabledByName(name: String): Boolean = featureTogglesMap[name] == true
fun isFeatureEnabledByName(name: String): Boolean = currentToggles.value.any { it.name == name && it.isEnabled }
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap
override fun getFeatureToggles(): List<FeatureToggleInfo> = currentToggles.value
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap
override fun isMatchLocalConfig(): Boolean =
currentToggles.value.associateBy { it.name } == fileFeatureToggles.associateBy { it.name }
override suspend fun changeToggle(name: String, isEnabled: Boolean) {
featureTogglesMap[name] ?: return
featureTogglesMap[name] = isEnabled
featureTogglesLocalStorage.store(value = featureTogglesMap)
if (currentToggles.value.none { it.name == name }) return
currentToggles.update { toggles ->
toggles.map { toggle ->
if (toggle.name == name) toggle.copy(isEnabled = isEnabled) else toggle
}
}
featureTogglesLocalStorage.store(value = currentToggles.value.toAvailabilityMap())
}
override suspend fun recoverLocalConfig() {
featureTogglesMap = fileFeatureTogglesMap.toMutableMap()
featureTogglesLocalStorage.store(value = fileFeatureTogglesMap)
currentToggles.value = fileFeatureToggles
featureTogglesLocalStorage.store(value = currentToggles.value.toAvailabilityMap())
}
override fun toString(): String {
return featureTogglesMap.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName)
}
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return featureTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
return currentToggles.value.toAvailabilityMap()
.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setFeatureToggles(map: MutableMap<String, Boolean>) {
featureTogglesMap = map
currentToggles.value = map.map { (name, isEnabled) ->
val version = fileFeatureToggles.firstOrNull { it.name == name }?.version.orEmpty()
FeatureToggleInfo(name = name, version = version, isEnabled = isEnabled)
}
}
private fun buildInitialToggles(): List<FeatureToggleInfo> {
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() }
return fileFeatureToggles.map { it.copy(isEnabled = savedFeatureToggles[it.name] ?: it.isEnabled) }
}
private fun buildFileFeatureToggles(): List<FeatureToggleInfo> {
val rawToggles = featureTogglesProvider.getToggles()
val availability = rawToggles.defineTogglesAvailability(appVersion = versionProvider.get())
return rawToggles.map { (name, version) ->
FeatureToggleInfo(name = name, version = version, isEnabled = availability.getValue(name))
}
}
private fun List<FeatureToggleInfo>.toAvailabilityMap(): Map<String, Boolean> =
associate { it.name to it.isEnabled }
}

View file

@ -1,6 +0,0 @@
package com.tangem.core.configtoggle.feature.impl
internal object FeatureTogglesConstants {
const val LOCAL_CONFIG_PATH: String = "configs/feature_toggles_config"
}

View file

@ -10,7 +10,7 @@ import com.tangem.utils.logging.TangemLogger
*
[REDACTED_AUTHOR]
*/
internal class Version private constructor(value: String) : Comparable<Version> {
class Version private constructor(value: String) : Comparable<Version> {
private val major: Int
private val minor: Int

View file

@ -1,5 +1,7 @@
package com.tangem.core.configtoggle.version
import com.tangem.core.configtoggle.feature.DISABLED_FEATURE_TOGGLE_VERSION
/**
* Version contract to evaluate availability of feature toggle
*
@ -7,8 +9,6 @@ package com.tangem.core.configtoggle.version
*/
internal object VersionAvailabilityContract {
private const val DISABLED_FEATURE_TOGGLE_VERSION = "undefined"
/** Evaluate availability of feature toggles using [currentVersion] and [localVersion] */
operator fun invoke(currentVersion: String, localVersion: String): Boolean {
if (localVersion == DISABLED_FEATURE_TOGGLE_VERSION) return false

View file

@ -2,7 +2,7 @@ package com.tangem.core.configtoggle.contract
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.version.VersionAvailabilityContract
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -2,7 +2,7 @@ package com.tangem.core.configtoggle.contract
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.version.Version
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -50,7 +50,6 @@ internal class FeatureTogglesNamingConventionTest {
"SOLANA_TX_HISTORY_ENABLED",
"STAKING_ETH_ENABLED",
"SWAP_AB_ENABLED",
"SWAP_INTEGRATED_APPROVE",
"USEDESK_ENABLED",
"VIRTUAL_ACCOUNTS_ENABLED",
"VISA_ONBOARDING_ENABLED",

View file

@ -1,6 +1,7 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.feature.FeatureToggleInfo
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
@ -28,6 +29,14 @@ internal class DevFeatureTogglesManagerTest {
version != "undefined" && !appVersion.isNullOrEmpty()
}
private fun Map<String, Boolean>.toToggleInfoList(): List<FeatureToggleInfo> = map { (name, isEnabled) ->
FeatureToggleInfo(
name = name,
version = testToggles.getValue(name),
isEnabled = isEnabled,
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@ -60,7 +69,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder {
versionProvider.get()
@ -86,7 +95,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder {
versionProvider.get()
@ -112,7 +121,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder {
versionProvider.get()
@ -159,7 +168,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert
val expected = getExpectedFileToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder {
versionProvider.get()
@ -185,7 +194,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert
val expected = fileToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder {
versionProvider.get()
@ -302,6 +311,59 @@ internal class DevFeatureTogglesManagerTest {
}
}
@Test
fun `isMatchLocalConfig is true when toggles match but order differs`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
// Same toggles and values as the local config, but in reversed order
val reorderedToggles = getExpectedFileToggles(appVersion = "1.0.0")
.entries.reversed()
.associate { it.key to it.value }
.toMutableMap()
val manager = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).apply {
setFeatureToggles(reorderedToggles)
}
// Act
val actual = manager.isMatchLocalConfig()
// Assert
Truth.assertThat(actual).isTrue()
}
@Test
fun `isMatchLocalConfig is false when a value differs despite reversed order`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
// Reversed order AND one toggle value flipped → must not match
val reorderedChangedToggles = getExpectedFileToggles(appVersion = "1.0.0")
.entries.reversed()
.associate { it.key to it.value }
.toMutableMap()
.apply { this["ENABLED_TOGGLE"] = false }
val manager = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).apply {
setFeatureToggles(reorderedChangedToggles)
}
// Act
val actual = manager.isMatchLocalConfig()
// Assert
Truth.assertThat(actual).isFalse()
}
private fun provideTestModels(): List<IsMatchLocalConfigModel> {
val appVersion = "1.0.0"
val fileToggles = getExpectedFileToggles(appVersion)
@ -369,7 +431,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert
val expected = fileToggles + savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder {
versionProvider.get()
@ -405,7 +467,7 @@ internal class DevFeatureTogglesManagerTest {
val actual = manager.getFeatureToggles()
// Assert
Truth.assertThat(actual).containsExactlyEntriesIn(model.expectedToggles)
Truth.assertThat(actual).containsExactlyElementsIn(model.expectedToggles.toToggleInfoList())
coVerifyOrder {
versionProvider.get()
@ -478,7 +540,7 @@ internal class DevFeatureTogglesManagerTest {
// Assert
val expected = getExpectedFileToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
coVerifyOrder {
versionProvider.get()

View file

@ -58,11 +58,6 @@ androidComponents {
variant.sources.java?.addGeneratedSourceDirectory(taskProvider, GenerateEnvironmentConfigTask::outputDir)
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Project */
@ -94,18 +89,19 @@ dependencies {
/** Coroutines */
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
implementation(deps.kotlin.datetime)
api(deps.kotlin.datetime)
api(deps.kotlin.serialization)
/** Logging */
/** Network */
implementation(deps.moshi)
api(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.moshi.adapters.ext)
implementation(deps.okHttp)
api(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.retrofit)
api(deps.retrofit)
implementation(deps.retrofit.moshi)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
@ -124,11 +120,10 @@ dependencies {
releaseImplementation(deps.chuckerStub)
/** Local storages */
implementation(deps.androidx.datastore)
api(deps.androidx.datastore)
implementation(deps.room.runtime)
implementation(deps.room.ktx)
ksp(deps.room.compiler)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "aafa8b51b5a5a32d0ec2b0720cec6c1e",
"identityHash": "442ac578743a8b624777711cf49c77e2",
"entities": [
{
"tableName": "express_provider",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon_url` TEXT NOT NULL, `provider_url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `image_large` TEXT NOT NULL, `image_small` TEXT NOT NULL, `terms_of_use` TEXT, `privacy_policy` TEXT, `is_recommended` INTEGER NOT NULL, `slippage` TEXT, `is_exchange_only_within_single_address` INTEGER NOT NULL, `is_extra_id_supported` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@ -21,16 +21,55 @@
"notNull": true
},
{
"fieldPath": "iconUrl",
"columnName": "icon_url",
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerUrl",
"columnName": "provider_url",
"fieldPath": "imageLarge",
"columnName": "image_large",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageSmall",
"columnName": "image_small",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "termsOfUse",
"columnName": "terms_of_use",
"affinity": "TEXT"
},
{
"fieldPath": "privacyPolicy",
"columnName": "privacy_policy",
"affinity": "TEXT"
},
{
"fieldPath": "isRecommended",
"columnName": "is_recommended",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slippage",
"columnName": "slippage",
"affinity": "TEXT"
},
{
"fieldPath": "isExchangeOnlyWithinSingleAddress",
"columnName": "is_exchange_only_within_single_address",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isExtraIdSupported",
"columnName": "is_extra_id_supported",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
@ -38,13 +77,11 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"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, `status` TEXT NOT NULL, `to_is_actual` INTEGER NOT NULL DEFAULT 0, `payin_hash` TEXT, `payout_hash` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `rate_type` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `from_network` TEXT NOT NULL, `from_token_id` TEXT, `from_raw_amount` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `to_network` TEXT NOT NULL, `to_token_id` TEXT, `to_raw_amount` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `refund_network` TEXT, `refund_token_id` TEXT, `refund_raw_amount` TEXT, `refund_decimals` INTEGER, `refund_hash` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
"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`))",
"fields": [
{
"fieldPath": "txId",
@ -65,41 +102,36 @@
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"fieldPath": "fromAddress",
"columnName": "from_address",
"affinity": "TEXT"
},
{
"fieldPath": "payinAddress",
"columnName": "payin_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toIsActual",
"columnName": "to_is_actual",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
"fieldPath": "payinExtraId",
"columnName": "payin_extra_id",
"affinity": "TEXT"
},
{
"fieldPath": "payinHash",
"columnName": "payin_hash",
"fieldPath": "payoutAddress",
"columnName": "payout_address",
"affinity": "TEXT",
"notNull": false
"notNull": true
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT",
"notNull": false
"fieldPath": "refundAddress",
"columnName": "refund_address",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT",
"notNull": false
"fieldPath": "refundExtraId",
"columnName": "refund_extra_id",
"affinity": "TEXT"
},
{
"fieldPath": "rateType",
@ -107,16 +139,68 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT"
},
{
"fieldPath": "payinHash",
"columnName": "payin_hash",
"affinity": "TEXT"
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT"
},
{
"fieldPath": "refundNetwork",
"columnName": "refund_network",
"affinity": "TEXT"
},
{
"fieldPath": "refundContractAddress",
"columnName": "refund_contract_address",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payTill",
"columnName": "pay_till",
"affinity": "TEXT"
},
{
"fieldPath": "averageDuration",
"columnName": "average_duration",
"affinity": "INTEGER"
},
{
"fieldPath": "from.contractAddress",
"columnName": "from_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
@ -125,18 +209,6 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.tokenId",
"columnName": "from_token_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "from.rawAmount",
"columnName": "from_raw_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.decimals",
"columnName": "from_decimals",
@ -144,20 +216,25 @@
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"fieldPath": "from.amount",
"columnName": "from_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.tokenId",
"columnName": "to_token_id",
"affinity": "TEXT",
"notNull": false
"fieldPath": "from.actualAmount",
"columnName": "from_actual_amount",
"affinity": "TEXT"
},
{
"fieldPath": "to.rawAmount",
"columnName": "to_raw_amount",
"fieldPath": "to.contractAddress",
"columnName": "to_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"affinity": "TEXT",
"notNull": true
},
@ -168,34 +245,15 @@
"notNull": true
},
{
"fieldPath": "refund.network",
"columnName": "refund_network",
"fieldPath": "to.amount",
"columnName": "to_amount",
"affinity": "TEXT",
"notNull": false
"notNull": true
},
{
"fieldPath": "refund.tokenId",
"columnName": "refund_token_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.rawAmount",
"columnName": "refund_raw_amount",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.decimals",
"columnName": "refund_decimals",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "refund.hash",
"columnName": "refund_hash",
"affinity": "TEXT",
"notNull": false
"fieldPath": "to.actualAmount",
"columnName": "to_actual_amount",
"affinity": "TEXT"
}
],
"primaryKey": {
@ -206,64 +264,33 @@
},
"indices": [
{
"name": "index_express_exchange_owner_address_from_network_updated_at",
"name": "index_express_exchange_owner_address_from_network_from_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"from_network",
"updated_at"
"from_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_updated_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `updated_at`)"
"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`)"
},
{
"name": "index_express_exchange_owner_address_payin_hash",
"name": "index_express_exchange_to_network_to_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"payin_hash"
"to_network",
"to_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_payin_hash` ON `${TABLE_NAME}` (`owner_address`, `payin_hash`)"
},
{
"name": "index_express_exchange_owner_address_payout_hash",
"unique": false,
"columnNames": [
"owner_address",
"payout_hash"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)"
},
{
"name": "index_express_exchange_owner_address_refund_hash",
"unique": false,
"columnNames": [
"owner_address",
"refund_hash"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_refund_hash` ON `${TABLE_NAME}` (`owner_address`, `refund_hash`)"
}
],
"foreignKeys": [
{
"table": "express_provider",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"provider_id"
],
"referencedColumns": [
"id"
]
"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`)"
}
]
},
{
"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, `status` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_token_id` TEXT, `to_expected_raw_amount` TEXT NOT NULL, `to_actual_raw_amount` TEXT, `to_decimals` INTEGER NOT NULL, `payout_hash` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `rate_type` TEXT NOT NULL, `fail_reason` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `refund_currency_code` TEXT, `refund_amount` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
"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`))",
"fields": [
{
"fieldPath": "txId",
@ -283,12 +310,50 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payoutAddress",
"columnName": "payout_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "failReason",
"columnName": "fail_reason",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT"
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fromCurrencyCode",
"columnName": "from_currency_code",
@ -302,88 +367,50 @@
"notNull": true
},
{
"fieldPath": "toNetwork",
"fieldPath": "fromPrecision",
"columnName": "from_precision",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "paymentMethod",
"columnName": "payment_method",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "countryCode",
"columnName": "country_code",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.contractAddress",
"columnName": "to_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toTokenId",
"columnName": "to_token_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "toExpectedRawAmount",
"columnName": "to_expected_raw_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toActualRawAmount",
"columnName": "to_actual_raw_amount",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "toDecimals",
"fieldPath": "to.decimals",
"columnName": "to_decimals",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT",
"notNull": false
"fieldPath": "to.amount",
"columnName": "to_amount",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rateType",
"columnName": "rate_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "failReason",
"columnName": "fail_reason",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "refund.currencyCode",
"columnName": "refund_currency_code",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.amount",
"columnName": "refund_amount",
"affinity": "TEXT",
"notNull": false
"fieldPath": "to.actualAmount",
"columnName": "to_actual_amount",
"affinity": "TEXT"
}
],
"primaryKey": {
@ -394,46 +421,64 @@
},
"indices": [
{
"name": "index_express_onramp_owner_address_to_network_updated_at",
"name": "index_express_onramp_owner_address_to_network_to_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"to_network",
"updated_at"
"to_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_updated_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `updated_at`)"
},
{
"name": "index_express_onramp_owner_address_payout_hash",
"unique": false,
"columnNames": [
"owner_address",
"payout_hash"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)"
}
],
"foreignKeys": [
{
"table": "express_provider",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"provider_id"
],
"referencedColumns": [
"id"
]
"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`)"
}
]
},
{
"tableName": "express_sync_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `address` TEXT NOT NULL, `is_initial_completed` INTEGER NOT NULL, `after_cursor` TEXT, `delta_cursor` TEXT, PRIMARY KEY(`type`, `address`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isInitialCompleted",
"columnName": "is_initial_completed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "afterCursor",
"columnName": "after_cursor",
"affinity": "TEXT"
},
{
"fieldPath": "deltaCursor",
"columnName": "delta_cursor",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"type",
"address"
]
}
}
],
"views": [],
"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, 'aafa8b51b5a5a32d0ec2b0720cec6c1e')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '442ac578743a8b624777711cf49c77e2')"
]
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.datasource.api.auth
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.response.NonceApiResponse
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.datasource.api.common.response.ApiResponse
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Tangem Auth Service API (JWT session tokens / DPoP interceptor / refresh rotation)
*/
interface AuthApi {
/**
* Request device registration nonce.
*
* Generates a nonce bound to the device public key for the device registration flow.
*/
@POST("api/v1/auth/nonce/device")
suspend fun requestDeviceNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Register device.
*
* 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>
/**
* Request authentication nonce.
*
* Generates a nonce bound to the device public key for the authentication flow.
*/
@POST("api/v1/auth/nonce/auth")
suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Authenticate device.
*
* Authenticates a previously registered device using a device-key signature. Issues a new
* JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after
* registration uses this endpoint.
*/
@POST("api/v1/auth/authenticate")
suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse>
/**
* Refresh tokens.
*
* Rotates the refresh token and issues a new access token. Uses refresh-token rotation
* 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")
@RequiresDpopProof
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
}

View file

@ -0,0 +1,56 @@
package com.tangem.datasource.api.auth
/**
* Marks a Retrofit endpoint as needing a DPoP proof header ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449))
* but **not** automatic refresh-on-401.
*
* Read at runtime by the DPoP authorization interceptor: methods carrying this annotation
* (or the umbrella [RequiresSessionAuth]) receive `Authorization: DPoP <access-token>` (when
* available) + `DPoP: <proof-jwt>` headers.
*
* Use this on endpoints that are themselves part of the refresh flow e.g. `/auth/refresh`
* to prevent the session authenticator from re-entering refresh on a 401 (which would deadlock
* the single-flight refresh mutex).
*
* For ordinary session-protected endpoints, prefer the combined [RequiresSessionAuth].
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresDpopProof
/**
* Marks a Retrofit endpoint as eligible for automatic session-token refresh on 401/403.
*
* Read at runtime by the session authenticator: methods carrying this annotation (or the
* umbrella [RequiresSessionAuth]) trigger `SessionTokenRefresher.refresh()` + a single retry
* with new tokens when the server responds with 401/403.
*
* Important: this annotation alone does **not** instruct the DPoP interceptor to add headers
* on the initial outgoing request. The retry built by the session authenticator after a
* successful refresh, however, always carries fresh `Authorization` / `DPoP` headers that
* happens regardless of which annotation gated the refresh.
*
* Rare in isolation proof and refresh-on-401 almost always travel together. Prefer the
* combined [RequiresSessionAuth] unless you have a concrete reason to omit proof on send.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionRefresh
/**
* Marks a Retrofit endpoint as fully session-protected ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)).
*
* Combines [RequiresDpopProof] (outgoing `Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`
* headers via the DPoP authorization interceptor) and [RequiresSessionRefresh] (automatic refresh +
* single retry on 401/403 via the session authenticator).
*
* Default choice for normal session-protected endpoints. Use the two specialised annotations only
* when you need exactly one of the behaviours typically `@RequiresDpopProof` on endpoints inside
* the refresh flow itself (`/auth/refresh`) to prevent recursion.
*
* Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the same
* on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionAuth

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Authentication request — authenticates a previously registered device. */
@JsonClass(generateAdapter = true)
data class AuthApiRequest(
/** Signed authentication payload. */
@Json(name = "payload") val payload: AuthenticationPayload,
/** EC signature over the authentication payload, signed by the device private key (Base64). */
@Json(name = "signature") val signature: String,
)
/** Signed authentication payload — the data that is signed by the device private key. */
@JsonClass(generateAdapter = true)
data class AuthenticationPayload(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
/** Deciphered nonce value from the nonce endpoint. */
@Json(name = "nonce") val nonce: 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

@ -0,0 +1,26 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Client-reported device metadata, included in both [AuthenticationPayload] and [RegisterPayload].
* Mirrors the `DeviceMetadata` schema in the backend OpenAPI contract.
*/
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@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?,
/** Application version (e.g. `5.8.0`). */
@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?,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for nonce generation (auth, upgrade, wallet flows). */
@JsonClass(generateAdapter = true)
data class NonceApiRequest(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token refresh request. */
@JsonClass(generateAdapter = true)
data class RefreshApiRequest(
/** Refresh token from a previous token response. */
@Json(name = "refreshToken") val refreshToken: String,
)

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
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
* [com.tangem.datasource.api.auth.models.response.TokenApiResponse] (the initial session token pair).
*/
@JsonClass(generateAdapter = true)
data class RegisterApiRequest(
/** Signed registration payload. */
@Json(name = "payload") val payload: RegisterPayload,
/** EC signature over the registration payload, signed by the device private key (Base64). */
@Json(name = "signature") val signature: String,
)
/** Signed registration payload — the data that is signed by the device private key. */
@JsonClass(generateAdapter = true)
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. */
@Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). Optional; backend accepts `null`. */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Ciphered nonce response. */
@JsonClass(generateAdapter = true)
data class NonceApiResponse(
/** RSA-OAEP ciphered nonce value (Base64). */
@Json(name = "cipheredNonce") val cipheredNonce: String,
/** Nonce expiration timestamp (ISO-8601). */
@Json(name = "expiresAt") val expiresAt: String,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token response — contains JWT access token and optional refresh token. */
@JsonClass(generateAdapter = true)
data class TokenApiResponse(
/** JWT access token (HMAC-SHA256 signed). */
@Json(name = "accessToken") val accessToken: String,
/** Access token expiration timestamp (ISO-8601). */
@Json(name = "accessTokenExpiresAt") val accessTokenExpiresAt: String,
/** Refresh token for token rotation. `null` for ORANGE tier (requires device challenge each time). */
@Json(name = "refreshToken") val refreshToken: String?,
/** Refresh token expiration timestamp (ISO-8601). `null` iff [refreshToken] is `null`. */
@Json(name = "refreshTokenExpiresAt") val refreshTokenExpiresAt: String?,
/** List of wallet IDs bound to this device. */
@Json(name = "walletIds") val walletIds: List<String>,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.auth.qualifier
import javax.inject.Qualifier
/**
* Marks the OkHttp [okhttp3.Interceptor] that attaches Tangem Auth Service session credentials
* (`Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`) to outgoing requests. The actual
* binding lives in `libs:auth` so this module does not depend on the auth library; Hilt assembles
* the binding at the `:app` level.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SessionAuthInterceptor
/**
* Marks the OkHttp [okhttp3.Authenticator] that rotates session tokens on 401/403 responses.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SessionAuthAuthenticator

View file

@ -33,6 +33,7 @@ sealed class ApiConfig {
News,
GaslessTxService,
SurveySparrow,
Auth,
}
private fun initializeId(): ID {
@ -49,6 +50,7 @@ sealed class ApiConfig {
is News -> ID.News
is GaslessTxService -> ID.GaslessTxService
is SurveySparrow -> ID.SurveySparrow
is Auth -> ID.Auth
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
/**
* Tangem Auth Service [ApiConfig] endpoints for device registration, authentication,
* nonce issuance, refresh token rotation, and JWKS publication.
*/
internal class Auth : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = DEV_BASE_URL,
headers = emptyMap(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = PROD_BASE_URL,
headers = emptyMap(),
)
private companion object {
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
private const val PROD_BASE_URL = "https://authentication.tangem.org/"
}
}

View file

@ -20,11 +20,13 @@ internal class GaslessTxService(
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createDevEnvironment(),
createMockedEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
@ -47,6 +49,12 @@ internal class GaslessTxService(
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = MOCK_BASE_URL,
headers = createHeaders(ApiEnvironment.MOCK),
)
private fun createHeaders(environment: ApiEnvironment) = buildMap {
putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values)
put(
@ -60,5 +68,6 @@ internal class GaslessTxService(
private companion object {
private const val PROD_BASE_URL = "https://gasless.tangem.org/"
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
private const val MOCK_BASE_URL = "[REDACTED_ENV_URL]"
}
}

View file

@ -78,7 +78,7 @@ interface TangemExpressApi {
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String?,
@Query("txId") txId: String,
): ApiResponse<ExchangeStatusResponse>
): ApiResponse<ExchangeItemResponse>
@POST("exchange-sent")
suspend fun exchangeSent(
@ -87,10 +87,19 @@ interface TangemExpressApi {
@Body body: ExchangeSentRequestBody,
): ApiResponse<ExchangeSentResponseBody>
@GET("exchange/history")
@GET("history/exchange")
suspend fun getHistory(
@Query("wallet_address") walletAddress: String,
@Query("cursor") cursor: String?,
@Header("user-id") userWalletId: String,
@Query("fromAddress") fromAddress: String,
@Query("afterCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<ExchangeHistoryResponse>
@GET("history/delta/exchange")
suspend fun getHistoryDelta(
@Header("user-id") userWalletId: String,
@Query("fromAddress") fromAddress: String,
@Query("beforeCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<ExchangeHistoryDeltaResponse>
}

View file

@ -5,81 +5,16 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeHistoryResponse(
@Json(name = "data")
val data: List<ExchangeRecord>,
@Json(name = "next_cursor")
val nextCursor: String,
@Json(name = "has_more")
val hasMore: Boolean,
) {
@Json(name = "items")
val items: List<ExchangeItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPagination,
)
@JsonClass(generateAdapter = true)
data class ExchangeRecord(
@Json(name = "tx_id")
val txId: String,
@Json(name = "status")
val status: String,
@Json(name = "provider")
val provider: Provider,
@Json(name = "from")
val from: AssetRef,
@Json(name = "to")
val to: AssetRef,
@Json(name = "payin_hash")
val payinHash: String?,
@Json(name = "payout_hash")
val payoutHash: String?,
@Json(name = "external_tx_id")
val externalTxId: String?,
@Json(name = "external_tx_url")
val externalTxUrl: String?,
@Json(name = "refund")
val refund: RefundInfo?,
@Json(name = "rate_type")
val rateType: String,
@Json(name = "created_at")
val createdAt: Long,
@Json(name = "updated_at")
val updatedAt: Long,
)
@JsonClass(generateAdapter = true)
data class Provider(
@Json(name = "id")
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "icon_url")
val iconUrl: String,
@Json(name = "provider_url")
val providerUrl: String,
)
@JsonClass(generateAdapter = true)
data class AssetRef(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "raw_amount")
val rawAmount: String,
@Json(name = "decimals")
val decimals: Int,
@Json(name = "is_actual")
val isActual: Boolean?,
)
@JsonClass(generateAdapter = true)
data class RefundInfo(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "raw_amount")
val rawAmount: String,
@Json(name = "decimals")
val decimals: Int,
@Json(name = "hash")
val hash: String?,
)
}
@JsonClass(generateAdapter = true)
data class ExchangeHistoryDeltaResponse(
@Json(name = "items")
val items: List<ExchangeItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPaginationDelta,
)

View file

@ -0,0 +1,120 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeItemResponse(
// region transaction info
@Json(name = "txId")
val txId: String,
@Json(name = "providerId")
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.
*/
@Json(name = "fromAddress")
val fromAddress: String?,
/** Address to which the source assets were transferred for the exchange */
@Json(name = "payinAddress")
val payinAddress: String,
/** Extra ID used for the pay-in transaction */
@Json(name = "payinExtraId")
val payinExtraId: String?,
/** Address that received the target assets */
@Json(name = "payoutAddress")
val payoutAddress: String,
/** Refund destination address */
@Json(name = "refundAddress")
val refundAddress: String?,
/** Extra ID used for refunds */
@Json(name = "refundExtraId")
val refundExtraId: String?,
/** Exchange rate type (e.g. float, fixed) */
@Json(name = "rateType")
val rateType: String,
/**
* Raw backend status string, kept unparsed so a new value never breaks deserialization.
* Typed view: [com.tangem.domain.express.models.ExpressExchangeStatus].
*/
@Json(name = "status")
val status: String,
/** External transaction ID (CEX only) */
@Json(name = "externalTxId")
val externalTxId: String?,
/** URL to view the transaction details (CEX only) */
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
/** Blockchain hash of the pay-in transaction */
@Json(name = "payinHash")
val payinHash: String?,
/** Blockchain hash of the payout transaction */
@Json(name = "payoutHash")
val payoutHash: String?,
/** Network used for the refund transaction */
@Json(name = "refundNetwork")
val refundNetwork: String?,
/** Refunded token contract address */
@Json(name = "refundContractAddress")
val refundContractAddress: String?,
/** Transaction creation timestamp in ISO-8601 format */
@Json(name = "createdAt")
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")
val payTill: String?,
/** Average provider exchange duration in seconds */
@Json(name = "averageDuration")
val averageDuration: Long?,
// endregion
// region fromAsset info
@Json(name = "fromContractAddress")
val fromContractAddress: String,
@Json(name = "fromNetwork")
val fromNetwork: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "fromAmount")
val fromAmount: String,
// endregion
// region toAsset info
@Json(name = "toContractAddress")
val toContractAddress: String,
@Json(name = "toNetwork")
val toNetwork: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "toAmount")
val toAmount: String,
@Json(name = "toActualAmount")
val toActualAmount: String?,
// endregion
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExpressPagination(
@Json(name = "endCursor")
val endCursor: String?,
@Json(name = "startDeltaCursor")
val startDeltaCursor: String?,
@Json(name = "hasMore")
val hasMore: Boolean,
)
@JsonClass(generateAdapter = true)
data class ExpressPaginationDelta(
@Json(name = "startCursor")
val startCursor: String?,
@Json(name = "hasMore")
val hasMore: Boolean,
)

View file

@ -3,9 +3,10 @@ package com.tangem.datasource.api.onramp
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest
import com.tangem.datasource.api.onramp.models.response.OnrampDataResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampQuoteResponse
import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
@ -86,12 +87,21 @@ interface OnrampApi {
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String?,
@Query("txId") txId: String,
): ApiResponse<OnrampStatusResponse>
): ApiResponse<OnrampItemResponse>
@GET("onramp/history")
@GET("history/onramp")
suspend fun getHistory(
@Query("wallet_address") walletAddress: String,
@Query("cursor") cursor: String?,
@Header("user-id") userWalletId: String,
@Query("payoutAddress") payoutAddress: String,
@Query("afterCursor") afterCursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<OnrampHistoryResponse>
@GET("history/delta/onramp")
suspend fun getHistoryDelta(
@Header("user-id") userWalletId: String,
@Query("payoutAddress") payoutAddress: String,
@Query("beforeCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<OnrampHistoryDeltaResponse>
}

View file

@ -2,86 +2,21 @@ package com.tangem.datasource.api.onramp.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.express.models.response.ExpressPagination
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
@JsonClass(generateAdapter = true)
data class OnrampHistoryResponse(
@Json(name = "data")
val data: List<OnrampRecord>,
@Json(name = "next_cursor")
val nextCursor: String,
@Json(name = "has_more")
val hasMore: Boolean,
) {
@Json(name = "items")
val items: List<OnrampItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPagination,
)
@JsonClass(generateAdapter = true)
data class OnrampRecord(
@Json(name = "tx_id")
val txId: String,
@Json(name = "status")
val status: String,
@Json(name = "provider")
val provider: Provider,
@Json(name = "from")
val from: FiatRef,
@Json(name = "to")
val to: OnrampAssetRef,
@Json(name = "payout_hash")
val payoutHash: String?,
@Json(name = "external_tx_id")
val externalTxId: String?,
@Json(name = "external_tx_url")
val externalTxUrl: String?,
@Json(name = "refund")
val refund: OnrampRefundInfo?,
@Json(name = "rate_type")
val rateType: String,
@Json(name = "fail_reason")
val failReason: String?,
@Json(name = "created_at")
val createdAt: Long,
@Json(name = "updated_at")
val updatedAt: Long,
)
@JsonClass(generateAdapter = true)
data class Provider(
@Json(name = "id")
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "icon_url")
val iconUrl: String,
@Json(name = "provider_url")
val providerUrl: String,
)
@JsonClass(generateAdapter = true)
data class FiatRef(
@Json(name = "currency_code")
val currencyCode: String,
@Json(name = "amount")
val amount: String,
)
@JsonClass(generateAdapter = true)
data class OnrampAssetRef(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "expected_raw_amount")
val expectedRawAmount: String,
@Json(name = "actual_raw_amount")
val actualRawAmount: String?,
@Json(name = "decimals")
val decimals: Int,
)
@JsonClass(generateAdapter = true)
data class OnrampRefundInfo(
@Json(name = "currency_code")
val currencyCode: String,
@Json(name = "amount")
val amount: String,
)
}
@JsonClass(generateAdapter = true)
data class OnrampHistoryDeltaResponse(
@Json(name = "items")
val items: List<OnrampItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPaginationDelta,
)

View file

@ -0,0 +1,85 @@
package com.tangem.datasource.api.onramp.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class OnrampItemResponse(
// region transaction info
@Json(name = "txId")
val txId: String,
@Json(name = "providerId")
val providerId: String,
/** Address that received the target assets */
@Json(name = "payoutAddress")
val payoutAddress: String,
/**
* Raw backend status string, kept unparsed so a new value never breaks deserialization.
* Typed view: [com.tangem.domain.express.models.ExpressOnrampStatus].
*/
@Json(name = "status")
val status: String,
/** Failure reason reported by the provider */
@Json(name = "failReason")
val failReason: String?,
/** External transaction ID reported by the provider in the webhook */
@Json(name = "externalTxId")
val externalTxId: String?,
/** URL to view the transaction details on the provider side (not provided by all providers) */
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
/** Blockchain hash of the payout transaction */
@Json(name = "payoutHash")
val payoutHash: String?,
/** Transaction creation timestamp in ISO-8601 format */
@Json(name = "createdAt")
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
@Json(name = "fromCurrencyCode")
val fromCurrencyCode: String,
@Json(name = "fromAmount")
val fromAmount: String,
@Json(name = "fromPrecision")
val fromPrecision: Int,
// endregion
// region toAsset info
@Json(name = "toContractAddress")
val toContractAddress: String,
@Json(name = "toNetwork")
val toNetwork: String,
@Json(name = "toDecimals")
val toDecimals: Int,
/** Provider-promised amount, received from the provider in the webhook */
@Json(name = "toAmount")
val toAmount: String?,
/** Actual amount delivered to the user, received from the provider in the webhook */
@Json(name = "toActualAmount")
val toActualAmount: String?,
// endregion
@Json(name = "paymentMethod")
val paymentMethod: String,
@Json(name = "countryCode")
val countryCode: String,
)

View file

@ -46,30 +46,50 @@ interface TangemPayApi {
@Path("order_id") orderId: String,
): ApiResponse<OrderResponse>
/**
* Find user orders, filtered by types and/or statuses. Source of truth for resolving active orders.
*
* Multiple values for the same query key are sent as repeated `order_types=A&order_types=B` params.
*/
@GET("v1/order")
suspend fun findOrders(
@Header("Authorization") authHeader: String,
@Query("order_types") orderTypes: List<String>?,
@Query("order_statuses") orderStatuses: List<String>?,
): ApiResponse<FindOrdersResponse>
@POST("v1/order")
suspend fun createOrder(
@Header("Authorization") authHeader: String,
@Body body: OrderRequest,
): ApiResponse<OrderResponse>
/** Customer offers — used to gate the issue-additional-card flow. */
@GET("v1/customer/offers")
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse>
@GET("v1/customer/balance")
suspend fun getCardBalance(@Header("Authorization") authHeader: String): ApiResponse<CardBalanceResponse>
@POST("v1/customer/card/details")
/** Card-scoped reveal. `{card_id}` = selected card id. */
@POST("v1/customer/card/{card_id}/details")
suspend fun revealCardDetails(
@Header("Authorization") authHeader: String,
@Path("card_id") cardId: String,
@Body body: CardDetailsRequest,
): ApiResponse<CardDetailsResponse>
@GET("v1/customer/card/pin")
@GET("v1/customer/card/{card_id}/pin")
suspend fun getPin(
@Header("Authorization") authHeader: String,
@Path("card_id") cardId: String,
@Header("X-Session-Id") sessionId: String,
): ApiResponse<GetPinResponse>
@PUT("v1/customer/card/pin")
@PUT("v1/customer/card/{card_id}/pin")
suspend fun setPin(
@Header("Authorization") authHeader: String,
@Path("card_id") cardId: String,
@Body body: SetPinRequest,
): ApiResponse<SetPinResponse>
@ -97,6 +117,12 @@ interface TangemPayApi {
@Body body: ReissueCardRequest,
): ApiResponse<ReissueCardResponse>
@POST("v1/customer/card/close")
suspend fun closeCard(
@Header("Authorization") authHeader: String,
@Body body: CloseCardRequest,
): ApiResponse<CloseCardResponse>
@POST("v1/customer/card/withdraw/data")
suspend fun getWithdrawData(
@Header("Authorization") authHeader: String,

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 CloseCardRequest(
@Json(name = "card_id") val cardId: String,
)

View file

@ -4,7 +4,10 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class OrderRequest(@Json(name = "data") val data: Data) {
data class OrderRequest(
@Json(name = "data") val data: Data,
@Json(name = "idempotency_key") val idempotencyKey: String,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "customer_wallet_address") val customerWalletAddress: String,

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 CloseCardResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: OrderResponse.Result.Status,
)
}

View file

@ -19,6 +19,8 @@ data class CustomerMeResponse(
@Json(name = "deposit_address") val depositAddress: String?,
@Json(name = "card") val card: Card?,
@Json(name = "balance") val balance: BalanceResponse?,
@Json(name = "product_instances") val productInstances: List<ProductInstance>,
@Json(name = "cards") val cards: List<Card>,
)
@JsonClass(generateAdapter = true)
@ -99,6 +101,9 @@ data class CustomerMeResponse(
@JsonClass(generateAdapter = true)
data class Card(
// Present in the multi-card `cards[]` array to join a card to its product instance;
// absent in the legacy single-card `card` object, where the card joins the single product instance.
@Json(name = "card_id") val cardId: String?,
@Json(name = "token") val token: String,
@Json(name = "expiration_month") val expirationMonth: String,
@Json(name = "expiration_year") val expirationYear: String,

View file

@ -0,0 +1,35 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Response from `GET /v1/customer/offers` list of offers available to the customer.
*
* Used to gate the issue-additional-card flow.
*/
@JsonClass(generateAdapter = true)
data class CustomerOffersResponse(
@Json(name = "result") val result: List<Offer>,
) {
@JsonClass(generateAdapter = true)
data class Offer(
@Json(name = "type") val type: String,
@Json(name = "fee") val fee: Fee,
@Json(name = "data") val data: Data,
)
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "specification_name") val specificationName: String,
@Json(name = "order_type") val orderType: String,
)
@JsonClass(generateAdapter = true)
data class Fee(
@Json(name = "amount") val amount: BigDecimal,
@Json(name = "currency") val currency: String,
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response from `GET /v1/order` (findOrders) array of orders matching the requested
* `order_types` / `order_statuses` filters.
*
* Each order shares the same shape as the single-order [OrderResponse.Result].
*/
@JsonClass(generateAdapter = true)
data class FindOrdersResponse(
@Json(name = "result") val result: List<OrderResponse.Result>,
)

View file

@ -113,4 +113,10 @@ internal object ApiConfigsModule {
fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig {
return SurveySparrow(environmentConfig)
}
@Provides
@IntoSet
fun provideAuthConfig(): ApiConfig {
return Auth()
}
}

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.api.common.adapter.*
import com.tangem.datasource.local.config.providers.models.ProviderModel
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM
import com.tangem.datasource.utils.SerializeNullsFactory
import com.tangem.domain.models.scan.serialization.*
import dagger.Module
@ -56,6 +57,18 @@ class MoshiModule {
.withSubtype(PaymentAccountStatusValueDM.DeactivatedAccount::class.java, "deactivated_account")
.withSubtype(PaymentAccountStatusValueDM.CardIssueFailed::class.java, "card_issue_failed"),
)
.add(
NamePolymorphicAdapterFactory.of(VirtualAccountStatusValueDM::class.java)
.withSubtype(VirtualAccountStatusValueDM.Empty::class.java, "empty")
.withSubtype(VirtualAccountStatusValueDM.NotCreated::class.java, "not_created")
.withSubtype(VirtualAccountStatusValueDM.UnderReview::class.java, "kyc_status")
.withSubtype(VirtualAccountStatusValueDM.Provisioning::class.java, "provisioning")
.withSubtype(
VirtualAccountStatusValueDM.CountryNotSupported::class.java,
"country_not_supported",
)
.withSubtype(VirtualAccountStatusValueDM.ActiveAccount::class.java, "active_account"),
)
.add(
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
import com.tangem.datasource.api.common.config.ApiConfig
@ -60,6 +61,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Express,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -69,6 +71,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.StakeKit,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -84,6 +87,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_90_SECONDS,
connectTimeoutSeconds = TIMEOUT_90_SECONDS,
@ -99,6 +103,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Express,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -108,6 +113,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = true,
sessionAuth = false,
)
}
@ -117,6 +123,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.YieldSupply,
applyTimeoutAnnotations = true,
sessionAuth = false,
)
}
@ -126,6 +133,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -141,6 +149,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -155,6 +164,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -169,6 +179,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPayAuth,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -178,6 +189,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.BlockAid,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -187,6 +199,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.SurveySparrow,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -196,6 +209,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.MoonPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -205,6 +219,21 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.News,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@Provides
@Singleton
fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Auth,
applyTimeoutAnnotations = false,
// Per-method annotations (`@RequiresDpopProof`, `@RequiresSessionAuth`) gate the hooks
// installed here. `/refresh` carries `@RequiresDpopProof` only, so the Authenticator
// skips it on 401 — no recursion into the refresher's mutex. Future session-protected
// endpoints (e.g. /wallet) will carry `@RequiresSessionAuth` and benefit from refresh-on-401.
sessionAuth = true,
)
}
@ -214,6 +243,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,

View file

@ -3,8 +3,12 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayCloseCardStore
import com.tangem.datasource.local.visa.DefaultTangemPayIssueCardStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.datasource.local.visa.TangemPayIssueCardStore
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import dagger.Module
import dagger.Provides
@ -32,4 +36,20 @@ internal object TangemPayStoresModule {
prefs = prefs,
)
}
@Provides
@Singleton
fun provideTangemPayCloseCardStore(prefs: AppPreferencesStore): TangemPayCloseCardStore {
return DefaultTangemPayCloseCardStore(
prefs = prefs,
)
}
@Provides
@Singleton
fun provideTangemPayIssueCardStore(prefs: AppPreferencesStore): TangemPayIssueCardStore {
return DefaultTangemPayIssueCardStore(
prefs = prefs,
)
}
}

View file

@ -1,23 +1,15 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import androidx.room.Room
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.store.CommonSyncState
import com.tangem.datasource.local.txhistory.store.CommonSyncStateKey
import com.tangem.datasource.local.txhistory.store.DefaultTxHistoryStore
import com.tangem.datasource.local.txhistory.store.TxHistoryStore
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.datasource.utils.KotlinxDataStoreSerializer.Companion.jsonBuilder
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
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.MapSerializer
import javax.inject.Singleton
@Module
@ -35,38 +27,15 @@ internal interface TxHistoryModule {
context = context,
klass = TxHistoryDatabase::class.java,
name = TX_HISTORY_DATABASE_NAME,
).build()
)
.fallbackToDestructiveMigration(true)
.build()
}
@Provides
@Singleton
fun provideTxHistoryStore(@ApplicationContext context: Context, appScope: AppCoroutineScope): TxHistoryStore {
val commonSerializer = KotlinxDataStoreSerializer(
defaultValue = emptyMap(),
serializer = MapSerializer(
CommonSyncStateKey.serializer(),
CommonSyncState.serializer(),
),
json = jsonBuilder {
allowStructuredMapKeys = true
},
)
fun provideExpressHistoryDao(database: TxHistoryDatabase): ExpressHistoryDao = database.expressHistoryDao()
val expressExchangeStore = DataStoreFactory.create(
serializer = commonSerializer,
produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressExchangeStore") },
scope = appScope,
)
val expressOnrampStore = DataStoreFactory.create(
serializer = commonSerializer,
produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressOnrampStore") },
scope = appScope,
)
return DefaultTxHistoryStore(
expressExchangeStore = expressExchangeStore,
expressOnrampStore = expressOnrampStore,
)
}
@Provides
fun provideSyncStateDao(database: TxHistoryDatabase): ExpressSyncStateDao = database.syncStateDao()
}
}

View file

@ -14,6 +14,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named
import javax.inject.Singleton
@Module
@ -26,6 +27,11 @@ internal object ConfigModule {
return GeneratedEnvironmentConfigConverter.convert()
}
@Provides
@Singleton
@Named("authServiceKey")
fun provideAuthServiceKey(environmentConfig: EnvironmentConfig): String? = environmentConfig.authServiceKey
@Provides
@Singleton
fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage {

View file

@ -5,6 +5,8 @@ import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.auth.qualifier.SessionAuthAuthenticator
import com.tangem.datasource.api.auth.qualifier.SessionAuthInterceptor
import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
@ -16,11 +18,16 @@ import com.tangem.datasource.api.utils.ConnectTimeout
import com.tangem.datasource.api.utils.ReadTimeout
import com.tangem.datasource.api.utils.WriteTimeout
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.datasource.utils.addHeaders
import com.tangem.utils.JsonStringValuesExtractor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Invocation
@ -28,6 +35,8 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Provider
import javax.inject.Singleton
/**
@ -41,6 +50,7 @@ import javax.inject.Singleton
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@Singleton
internal class RetrofitApiBuilder @Inject constructor(
private val apiConfigs: ApiConfigs,
@ -49,15 +59,32 @@ internal class RetrofitApiBuilder @Inject constructor(
private val analyticsErrorHandler: AnalyticsErrorHandler,
@ApplicationContext private val context: Context,
private val appLogsStore: AppLogsStore,
private val environmentConfig: EnvironmentConfig,
@SessionAuthInterceptor private val sessionAuthInterceptor: Provider<Interceptor>,
@SessionAuthAuthenticator private val sessionAuthenticator: Provider<Authenticator>,
@Named("isBackendAuthenticationEnabled") private val isBackendAuthEnabled: Provider<Boolean>,
) {
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
private val sensitiveUrlMasker: SensitiveUrlMasker by lazy {
val json = Json.encodeToJsonElement(EnvironmentConfig.serializer(), environmentConfig)
// Drop URL-shaped values (e.g. public endpoint URLs from config); they are not secrets
// and would obscure unrelated requests in logs.
val values = JsonStringValuesExtractor.extract(json)
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
SensitiveUrlMasker(values)
}
/**
* Builds a Retrofit API instance for the specified API configuration ID
*
* @param apiConfigId the ID of the API configuration to use
* @param applyTimeoutAnnotations whether to apply timeout annotations to the requests. See [ReadTimeout], etc.
* @param sessionAuth when `true`, installs the DPoP `Interceptor` and 401/403
* `Authenticator` from `libs:auth`. Per-method annotations
* (`@RequiresDpopProof`, `@RequiresSessionRefresh`,
* `@RequiresSessionAuth`) gate which methods opt into each hook
* @param timeouts optional timeouts for the requests
* @param logsSaving whether to enable logs saving
*
@ -66,6 +93,7 @@ internal class RetrofitApiBuilder @Inject constructor(
inline fun <reified T> build(
apiConfigId: ApiConfig.ID,
applyTimeoutAnnotations: Boolean,
sessionAuth: Boolean,
timeouts: Timeouts? = null,
logsSaving: Boolean = true,
): T {
@ -79,6 +107,7 @@ internal class RetrofitApiBuilder @Inject constructor(
OkHttpClient.Builder()
.applyApiConfig(apiConfigId = apiConfigId, environmentConfig = environmentConfig)
.applyWireMockRedirect()
.applySessionAuth(sessionAuth)
.let {
if (applyTimeoutAnnotations) it.applyTimeoutAnnotations() else it
}
@ -93,6 +122,19 @@ internal class RetrofitApiBuilder @Inject constructor(
.create(T::class.java)
}
@PublishedApi
internal fun OkHttpClient.Builder.applySessionAuth(condition: Boolean): OkHttpClient.Builder {
// Belt-and-suspenders: callers opt in via the `sessionAuth` flag, but if the backend-auth
// 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())
}
return this
}
data class Timeouts(
val callTimeoutSeconds: Long? = null,
val connectTimeoutSeconds: Long? = null,
@ -179,7 +221,7 @@ internal class RetrofitApiBuilder @Inject constructor(
private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder {
return addInterceptor(
interceptor = NetworkLogsSaveInterceptor(appLogsStore),
interceptor = NetworkLogsSaveInterceptor(appLogsStore, sensitiveUrlMasker),
)
}

View file

@ -13,4 +13,6 @@ data class UsedCardInfo(
val isActivationStarted: Boolean = false,
@Json(name = "isActivationFinished")
val isActivationFinished: Boolean = false,
@Json(name = "hasBackupError")
val hasBackupError: Boolean = false,
)

View file

@ -4,7 +4,10 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.datasource.local.config.environment.models.ExpressModel
import com.tangem.datasource.local.config.environment.models.P2PKeys
import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class EnvironmentConfig(
val moonPayApiKey: String = "",
val moonPayApiSecretKey: String = "",
@ -32,5 +35,7 @@ data class EnvironmentConfig(
val gaslessTxApiKey: String? = null,
val customerIoCdpApiKey: String? = null,
val surveySparrowToken: String? = null,
@Transient
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
val authServiceKey: String? = null,
)

View file

@ -56,6 +56,7 @@ internal object GeneratedEnvironmentConfigConverter {
customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey,
surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey,
surveySparrowSwapRating = createSurveySparrowSwapRating(),
authServiceKey = GeneratedEnvironmentConfig.authServiceKey,
)
}

View file

@ -1,7 +1,11 @@
package com.tangem.datasource.local.config.environment.models
import kotlinx.serialization.Serializable
@Serializable
data class ExpressModel(val apiKey: String, val signVerifierPublicKey: String)
@Serializable
data class P2PKeys(val mainnet: String, val hoodi: String)
data class SurveySparrowSwapRatingConfig(

View file

@ -0,0 +1,79 @@
package com.tangem.datasource.local.converter
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
/**
* 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 {
return ExpressExchangeEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
fromAddress = fromAddress,
payinAddress = payinAddress,
payinExtraId = payinExtraId,
payoutAddress = payoutAddress,
refundAddress = refundAddress,
refundExtraId = refundExtraId,
rateType = rateType,
status = status,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = createdAt,
updatedAt = ""/*updatedAt*/, // todo txHistory uncomment
payTill = payTill,
averageDuration = averageDuration,
from = ExpressExchangeEntity.AssetEmbedded(
contractAddress = fromContractAddress,
network = fromNetwork,
decimals = fromDecimals,
amount = fromAmount,
actualAmount = null,
),
to = ExpressExchangeEntity.AssetEmbedded(
contractAddress = toContractAddress,
network = toNetwork,
decimals = toDecimals,
amount = toAmount,
actualAmount = toActualAmount,
),
)
}
fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity {
return ExpressOnrampEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
payoutAddress = payoutAddress,
status = status,
failReason = failReason,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = createdAt,
updatedAt = ""/*updatedAt*/, // todo txHistory uncomment,
fromCurrencyCode = fromCurrencyCode,
fromAmount = fromAmount,
fromPrecision = fromPrecision,
to = ExpressOnrampEntity.AssetEmbedded(
contractAddress = toContractAddress,
network = toNetwork,
decimals = toDecimals,
amount = toAmount,
actualAmount = toActualAmount,
),
paymentMethod = paymentMethod,
countryCode = countryCode,
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.local.converter
import com.tangem.datasource.api.express.models.response.ExchangeProvider
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
/**
* Maps an [ExchangeProvider] API response into its persisted [ExpressProviderEntity] representation.
*
* `type` is stored as the [com.tangem.datasource.api.express.models.response.ExchangeProviderType] name
* (DEX / CEX / DEX_BRIDGE / ONRAMP); `slippage` as a plain decimal string.
*/
fun ExchangeProvider.toEntity(): ExpressProviderEntity {
return ExpressProviderEntity(
id = id,
name = name,
type = type.name,
imageLarge = imageLargeUrl,
imageSmall = imageSmallUrl,
termsOfUse = termsOfUse,
privacyPolicy = privacyPolicy,
isRecommended = isRecommended,
slippage = slippage?.toPlainString(),
isExchangeOnlyWithinSingleAddress = isExchangeOnlyWithinSingleAddress,
isExtraIdSupported = isExtraIdSupported,
)
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.logs
class SensitiveUrlMasker(sensitiveValues: Collection<String>) {
// Sorted by descending length so a value that is a prefix of another (e.g. "my-node" vs
// "my-node-prod") cannot mask the shorter one first and leave the suffix in the log.
private val sensitiveValues: List<String> = sensitiveValues
.distinct()
.sortedByDescending(String::length)
fun mask(url: String): String {
var result = url
for (value in sensitiveValues) {
if (result.contains(value, ignoreCase = true)) {
result = result.replace(value, MASKED_VALUE, ignoreCase = true)
}
}
return result
}
companion object {
const val MASKED_VALUE = "******"
}
}

View file

@ -103,6 +103,8 @@ object PreferencesKeys {
val IS_GOOGLE_PAY_AVAILABLE_KEY by lazy { booleanPreferencesKey(name = "isGooglePayAvailable") }
val IS_DEVICE_REGISTERED_KEY by lazy { booleanPreferencesKey(name = "isDeviceRegistered") }
val WAS_LOG_FILE_CLEARED by lazy { booleanPreferencesKey(name = "wasLogFileCleared") }
val SEED_FIRST_NOTIFICATION_SHOW_TIME by lazy { longPreferencesKey("seedFirstNotificationTime") }

View file

@ -2,7 +2,9 @@ package com.tangem.datasource.local.txhistory.db
import androidx.room.Database
import androidx.room.RoomDatabase
import com.tangem.datasource.local.txhistory.db.entity.ExpressHistoryDao
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.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
@ -13,9 +15,12 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEn
ExpressProviderEntity::class,
ExpressExchangeEntity::class,
ExpressOnrampEntity::class,
ExpressSyncStateEntity::class,
],
)
abstract class TxHistoryDatabase : RoomDatabase() {
abstract fun expressHistoryDao(): ExpressHistoryDao
abstract fun syncStateDao(): ExpressSyncStateDao
}

View file

@ -0,0 +1,97 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy
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 kotlinx.coroutines.flow.Flow
@Dao
interface ExpressHistoryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertProviders(items: List<ExpressProviderEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertExchanges(items: List<ExpressExchangeEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertOnramps(items: List<ExpressOnrampEntity>)
/**
* All persisted providers keyed by [ExpressProviderEntity.id]
*/
@Query("SELECT * FROM express_provider")
fun getProvidersById(): Flow<Map<@MapColumn(columnName = "id") String, ExpressProviderEntity>>
/**
* 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`.
*
* loading the whole table; [activeStatuses] keeps in-progress deals visible even outside the window.
*/
@Query(
"""
SELECT * FROM express_exchange
WHERE owner_address = :ownerAddress
AND from_network = :network
AND from_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeOutgoingSwaps(
ownerAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): 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`.
*/
@Query(
"""
SELECT * FROM express_exchange
WHERE to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeIncomingSwaps(
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): Flow<List<ExpressExchangeEntity>>
/**
* Onramp is always incoming: [ExpressOnrampEntity.ownerAddress] == payoutAddress. Join by `payout_hash`.
*/
@Query(
"""
SELECT * FROM express_onramp
WHERE owner_address = :ownerAddress
AND to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeIncomingOnramps(
ownerAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): Flow<List<ExpressOnrampEntity>>
}

View file

@ -0,0 +1,47 @@
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.ExpressSyncStateEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ExpressSyncStateDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(item: ExpressSyncStateEntity)
@Query(
"""
UPDATE express_sync_state
SET after_cursor = :afterCursor,
is_initial_completed = :isInitialCompleted
WHERE type = :type
AND address = :address
""",
)
suspend fun updateHistoryCursor(type: String, address: String, afterCursor: String?, isInitialCompleted: Boolean)
@Query(
"""
UPDATE express_sync_state
SET delta_cursor = :deltaCursor
WHERE type = :type
AND address = :address
""",
)
suspend fun updateDeltaCursor(type: String, address: String, deltaCursor: String)
@Query(
"""
SELECT *
FROM express_sync_state
WHERE type = :type
AND address = :address
LIMIT 1
""",
)
fun observe(type: String, address: String): Flow<ExpressSyncStateEntity?>
}

View file

@ -1,87 +0,0 @@
package com.tangem.datasource.local.txhistory.db.entity
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.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ExpressHistoryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertProviders(items: List<ExpressProviderEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertExchanges(items: List<ExpressExchangeEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertOnramps(items: List<ExpressOnrampEntity>)
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
ORDER BY updated_at DESC
""",
)
fun observeExchanges(ownerAddress: String): Flow<List<ExpressExchangeEntity>>
@Query(
"""
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
ORDER BY updated_at DESC
""",
)
fun observeOnramps(ownerAddress: String): Flow<List<ExpressOnrampEntity>>
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND payin_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByPayinHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND payout_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByPayoutHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND refund_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByRefundHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
AND payout_hash = :hash
LIMIT 1
""",
)
suspend fun findOnrampByPayoutHash(ownerAddress: String, hash: String): ExpressOnrampEntity?
}

View file

@ -2,22 +2,19 @@ package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.*
@Suppress("BooleanPropertyNaming")
/**
* Persisted representation of a single exchange transaction.
*
* Mirrors [com.tangem.datasource.api.express.models.response.ExchangeItemResponse].
*/
@Entity(
tableName = "express_exchange",
foreignKeys = [
ForeignKey(
entity = ExpressProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [
Index(value = ["owner_address", "from_network", "updated_at"]),
Index(value = ["owner_address", "payin_hash"]),
Index(value = ["owner_address", "payout_hash"]),
Index(value = ["owner_address", "refund_hash"]),
// 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"]),
],
)
data class ExpressExchangeEntity(
@ -26,6 +23,9 @@ 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,
@ -33,48 +33,31 @@ data class ExpressExchangeEntity(
val providerId: String,
/**
* waiting
* confirming
* exchanging
* sending
* finished
* failed
* refunded
* expired
* 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.
*/
@ColumnInfo(name = "status")
val status: String,
@ColumnInfo(name = "from_address")
val fromAddress: String?,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
/** Address to which the source assets were transferred for the exchange */
@ColumnInfo(name = "payin_address")
val payinAddress: String,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
/** Extra ID used for the pay-in transaction */
@ColumnInfo(name = "payin_extra_id")
val payinExtraId: String?,
/**
* true -> actual provider-confirmed amount
* false -> estimated amount
*/
@ColumnInfo(name = "to_is_actual", defaultValue = "0")
val toIsActual: Boolean,
/** Address that received the target assets */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,
/**
* Match key for PAYIN leg
*/
@ColumnInfo(name = "payin_hash")
val payinHash: String?,
/** Refund destination address */
@ColumnInfo(name = "refund_address")
val refundAddress: String?,
/**
* Match key for PAYOUT leg
*/
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Extra ID used for refunds */
@ColumnInfo(name = "refund_extra_id")
val refundExtraId: String?,
/**
* fixed / float
@ -82,49 +65,76 @@ data class ExpressExchangeEntity(
@ColumnInfo(name = "rate_type")
val rateType: String,
/**
* Raw backend status string, persisted as-is (kept unparsed so a new value never breaks anything).
* Typed view: [com.tangem.domain.express.models.ExpressExchangeStatus].
*/
@ColumnInfo(name = "status")
val status: String,
/** External transaction ID (CEX only) */
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
/** URL to view the transaction details (CEX only) */
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Blockchain hash of the pay-in transaction */
@ColumnInfo(name = "payin_hash")
val payinHash: String?,
/** Blockchain hash of the payout transaction */
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
/** Network used for the refund transaction */
@ColumnInfo(name = "refund_network")
val refundNetwork: String?,
/** Refunded token contract address */
@ColumnInfo(name = "refund_contract_address")
val refundContractAddress: String?,
/** Transaction creation timestamp in ISO-8601 format */
@ColumnInfo(name = "created_at")
val createdAt: Long,
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
val updatedAt: String,
@Embedded(prefix = "refund_")
val refund: RefundEmbedded?,
/** Pay-in expiration timestamp in ISO-8601 format */
@ColumnInfo(name = "pay_till")
val payTill: String?,
/** Average provider exchange duration in seconds */
@ColumnInfo(name = "average_duration")
val averageDuration: Long?,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
) {
data class AssetEmbedded(
@ColumnInfo(name = "contract_address")
val contractAddress: String,
@ColumnInfo(name = "network")
val network: String,
@ColumnInfo(name = "token_id")
val tokenId: String?,
@ColumnInfo(name = "raw_amount")
val rawAmount: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
)
data class RefundEmbedded(
@ColumnInfo(name = "amount")
val amount: String,
@ColumnInfo(name = "network")
val network: String?,
@ColumnInfo(name = "token_id")
val tokenId: String?,
@ColumnInfo(name = "raw_amount")
val rawAmount: String?,
@ColumnInfo(name = "decimals")
val decimals: Int?,
/**
* Match key for REFUND leg
*/
@ColumnInfo(name = "hash")
val hash: String?,
/** Actual provider-confirmed amount. Present only for the [ExpressExchangeEntity.to] asset */
@ColumnInfo(name = "actual_amount")
val actualAmount: String?,
)
}

View file

@ -2,19 +2,16 @@ package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.*
/**
* Persisted representation of a single onramp transaction.
*
* Mirrors [com.tangem.datasource.api.onramp.models.response.OnrampItemResponse].
*/
@Entity(
tableName = "express_onramp",
foreignKeys = [
ForeignKey(
entity = ExpressProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [
Index(value = ["owner_address", "to_network", "updated_at"]),
Index(value = ["owner_address", "payout_hash"]),
// Incoming onramp lookup (observeIncomingOnramps): owner + to-asset equality, created_at range/sort.
Index(value = ["owner_address", "to_network", "to_contract_address", "created_at"]),
],
)
data class ExpressOnrampEntity(
@ -23,100 +20,89 @@ 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 */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,
* waiting-for-payment
* payment-processing
* paused
* verifying
* sending
* finished
* failed
* expired
* refunded
/**
* Raw backend status string, persisted as-is (kept unparsed so a new value never breaks anything).
* Typed view: [com.tangem.domain.express.models.ExpressOnrampStatus].
*/
@ColumnInfo(name = "status")
val status: String,
/**
* ISO-4217
*/
@ColumnInfo(name = "from_currency_code")
val fromCurrencyCode: String,
/**
* Decimal string
*/
@ColumnInfo(name = "from_amount")
val fromAmount: String,
@ColumnInfo(name = "to_network")
val toNetwork: String,
@ColumnInfo(name = "to_token_id")
val toTokenId: String?,
/**
* Estimated amount at creation moment
*/
@ColumnInfo(name = "to_expected_raw_amount")
val toExpectedRawAmount: String,
/**
* Actual provider-confirmed amount
*/
@ColumnInfo(name = "to_actual_raw_amount")
val toActualRawAmount: String?,
@ColumnInfo(name = "to_decimals")
val toDecimals: Int,
/**
* Match key with gateway_tx.hash
*/
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/**
* fixed / float
*/
@ColumnInfo(name = "rate_type")
val rateType: String,
/** Failure reason reported by the provider */
@ColumnInfo(name = "fail_reason")
val failReason: String?,
/** External transaction ID reported by the provider in the webhook */
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
/** URL to view the transaction details on the provider side (not provided by all providers) */
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Blockchain hash of the payout transaction */
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
/** Transaction creation timestamp in ISO-8601 format */
@ColumnInfo(name = "created_at")
val createdAt: Long,
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
val updatedAt: String,
@Embedded(prefix = "refund_")
val refund: RefundEmbedded?,
/** Fiat currency code of the source funds */
@ColumnInfo(name = "from_currency_code")
val fromCurrencyCode: String,
/** Fiat amount of the source funds */
@ColumnInfo(name = "from_amount")
val fromAmount: String,
/** Number of decimal places of the source fiat currency */
@ColumnInfo(name = "from_precision")
val fromPrecision: Int,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
@ColumnInfo(name = "payment_method")
val paymentMethod: String,
@ColumnInfo(name = "country_code")
val countryCode: String,
) {
data class RefundEmbedded(
data class AssetEmbedded(
/**
* ISO-4217
*/
@ColumnInfo(name = "currency_code")
val currencyCode: String?,
@ColumnInfo(name = "contract_address")
val contractAddress: String,
@ColumnInfo(name = "network")
val network: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
/** Provider-promised amount. Present only if the provider reported it */
@ColumnInfo(name = "amount")
val amount: String?,
/** Actual provider-confirmed amount delivered to the user */
@ColumnInfo(name = "actual_amount")
val actualAmount: String?,
)
}

View file

@ -4,9 +4,13 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "express_provider",
)
/**
* Persisted representation of an express provider.
*
* Mirrors [com.tangem.datasource.api.express.models.response.ExchangeProvider]. Mapped into
* [com.tangem.domain.express.models.ExpressProvider] when read back.
*/
@Entity(tableName = "express_provider")
data class ExpressProviderEntity(
@PrimaryKey
@ -16,9 +20,34 @@ data class ExpressProviderEntity(
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "icon_url")
val iconUrl: String,
/** Raw provider type (`dex` / `cex` / `dex-bridge` / `onramp`). Typed view: ExpressProviderType. */
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "provider_url")
val providerUrl: String,
/** Large logo image URL. */
@ColumnInfo(name = "image_large")
val imageLarge: String,
/** Small logo image URL. */
@ColumnInfo(name = "image_small")
val imageSmall: String,
@ColumnInfo(name = "terms_of_use")
val termsOfUse: String?,
@ColumnInfo(name = "privacy_policy")
val privacyPolicy: String?,
@ColumnInfo(name = "is_recommended")
val isRecommended: Boolean,
/** Raw decimal string (BigDecimal) or `null`. */
@ColumnInfo(name = "slippage")
val slippage: String?,
@ColumnInfo(name = "is_exchange_only_within_single_address")
val isExchangeOnlyWithinSingleAddress: Boolean,
@ColumnInfo(name = "is_extra_id_supported")
val isExtraIdSupported: Boolean,
)

View file

@ -0,0 +1,39 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Entity
/**
* Persisted sync state of the express tx history.
*
* Stored inside [com.tangem.datasource.local.txhistory.db.TxHistoryDatabase] on purpose: if the history tables are
* dropped (e.g. destructive migration), the sync state is wiped together with them and the history is re-synced
* from scratch.
*/
@Entity(
tableName = "express_sync_state",
primaryKeys = ["type", "address"],
)
data class ExpressSyncStateEntity(
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "address")
val address: String,
@ColumnInfo(name = "is_initial_completed")
val isInitialCompleted: Boolean,
@ColumnInfo(name = "after_cursor")
val afterCursor: String?,
@ColumnInfo(name = "delta_cursor")
val deltaCursor: String?,
) {
enum class Type {
EXCHANGE,
ONRAMP,
}
}

View file

@ -1,38 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultTxHistoryStore(
private val expressExchangeStore: DataStore<Map<CommonSyncStateKey, CommonSyncState>>,
private val expressOnrampStore: DataStore<Map<CommonSyncStateKey, CommonSyncState>>,
) : TxHistoryStore {
override fun expressExchangeSyncState(key: CommonSyncStateKey): Flow<CommonSyncState> {
return expressExchangeStore.data.map { map -> map.getOrDefault(key) }
}
override fun expressOnrampSyncState(key: CommonSyncStateKey): Flow<CommonSyncState> {
return expressOnrampStore.data.map { map -> map.getOrDefault(key) }
}
override suspend fun updateExpressExchangeSyncState(
key: CommonSyncStateKey,
value: CommonSyncState,
): CommonSyncState {
return expressExchangeStore.updateData { map -> map.plus(key to value) }
.getOrDefault(key)
}
override suspend fun updateExpressOnrampSyncState(
key: CommonSyncStateKey,
value: CommonSyncState,
): CommonSyncState {
return expressOnrampStore.updateData { map -> map.plus(key to value) }
.getOrDefault(key)
}
private fun Map<CommonSyncStateKey, CommonSyncState>.getOrDefault(key: CommonSyncStateKey): CommonSyncState =
this.getOrDefault(key, CommonSyncState.default(key))
}

View file

@ -1,27 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import com.tangem.domain.models.account.AccountId
import kotlinx.serialization.Serializable
@Serializable
data class CommonSyncStateKey(
val accountId: AccountId,
val address: String,
)
@Serializable
data class CommonSyncState(
val accountId: AccountId,
val address: String,
val isInitialCompleted: Boolean,
val cursor: String?,
) {
companion object {
fun default(key: CommonSyncStateKey) = CommonSyncState(
accountId = key.accountId,
address = key.address,
isInitialCompleted = false,
cursor = null,
)
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import kotlinx.coroutines.flow.Flow
interface TxHistoryStore {
fun expressExchangeSyncState(key: CommonSyncStateKey): Flow<CommonSyncState>
fun expressOnrampSyncState(key: CommonSyncStateKey): Flow<CommonSyncState>
suspend fun updateExpressExchangeSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState
suspend fun updateExpressOnrampSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState
}

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
internal class DefaultTangemPayCardFrozenStateStore(

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
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 getOrderId(cardId: String): String? {
return prefs.getSyncOrNull(key = getCloseKey(cardId))
}
private fun getCloseKey(cardId: String) = stringPreferencesKey("tangem_pay_close_card_$cardId")
}

View file

@ -0,0 +1,33 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getObjectListSync
import com.tangem.datasource.local.preferences.utils.storeObjectList
import com.tangem.domain.models.wallet.UserWalletId
internal class DefaultTangemPayIssueCardStore(
private val prefs: AppPreferencesStore,
) : TangemPayIssueCardStore {
override suspend fun addIssueOrderId(userWalletId: UserWalletId, orderId: String) {
val current = prefs.getObjectListSync<String>(getKey(userWalletId))
if (orderId !in current) {
prefs.storeObjectList(key = getKey(userWalletId), value = current + orderId)
}
}
override suspend fun getIssueOrderIds(userWalletId: UserWalletId): List<String> {
return prefs.getObjectListSync(getKey(userWalletId))
}
override suspend fun removeIssueOrderId(userWalletId: UserWalletId, orderId: String) {
val current = prefs.getObjectListSync<String>(getKey(userWalletId))
if (orderId in current) {
prefs.storeObjectList(key = getKey(userWalletId), value = current - orderId)
}
}
private fun getKey(userWalletId: UserWalletId) =
stringPreferencesKey("tangem_pay_issue_card_orders_${userWalletId.stringValue}")
}

View file

@ -1,6 +1,6 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
interface TangemPayCardFrozenStateStore {

View file

@ -0,0 +1,8 @@
package com.tangem.datasource.local.visa
interface TangemPayCloseCardStore {
suspend fun setCloseOrderId(cardId: String, orderId: String?)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.wallet.UserWalletId
/**
* Local store for additional-card issuance order ids.
*
* The backend `customer/me` response does not surface a card while it is still being issued, so the
* order id of an in-flight additional-card issuance is persisted here (per wallet) to render an
* "issuing" placeholder card until the order reaches a terminal state and the real card appears.
*/
interface TangemPayIssueCardStore {
suspend fun addIssueOrderId(userWalletId: UserWalletId, orderId: String)
suspend fun getIssueOrderIds(userWalletId: UserWalletId): List<String>
suspend fun removeIssueOrderId(userWalletId: UserWalletId, orderId: String)
}

View file

@ -40,6 +40,7 @@ sealed interface PaymentAccountStatusValueDM {
@NameLabel("active_account")
data class ActiveAccount(
@Json(name = "active_account") val marker: Boolean = true,
@Json(name = "customer_id") val customerId: String,
@Json(name = "currency_code") val currencyCode: String,
@Json(name = "deposit_address") val depositAddress: String?,
@ -59,9 +60,11 @@ sealed interface PaymentAccountStatusValueDM {
@NameLabel("deactivated_account")
data class DeactivatedAccount(
@Json(name = "deactivated_account") val marker: Boolean = true,
@Json(name = "customer_id") val customerId: String,
@Json(name = "fiat_rate") val fiatRate: BigDecimal?,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
@Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal,
) : PaymentAccountStatusValueDM
@JsonClass(generateAdapter = true)
@ -82,12 +85,14 @@ sealed interface PaymentAccountStatusValueDM {
@JsonClass(generateAdapter = true)
data class TangemPayCard(
@Json(name = "id") val id: String,
@Json(name = "product_instance_id") val productInstanceId: String,
@Json(name = "card_status") val cardStatus: String,
@Json(name = "has_pin_code") val hasPinCode: Boolean,
@Json(name = "display_name") val displayName: String?,
@Json(name = "actual_daily_limit") val actualDailyLimit: SerializedBigDecimal?,
@Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?,
@Json(name = "is_frozen") val isFrozen: Boolean,
@Json(name = "frozen_state") val frozenState: String,
@Json(name = "last_digits") val lastDigits: String,
@Json(name = "is_reissuing") val isReissuing: Boolean,
@Json(name = "state") val state: String,
)
}

View file

@ -0,0 +1,71 @@
@file:Suppress("BooleanPropertyNaming")
package com.tangem.datasource.local.visa.entity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.kyc.KycStatus
import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType
import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel
import java.math.BigDecimal
/**
* Virtual account status for storage in the local cache.
*
* @see [com.tangem.domain.models.account.AccountStatus.Virtual]
*/
@JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER)
sealed interface VirtualAccountStatusValueDM {
@NameLabel("empty")
data class Empty(
@Json(name = "empty") val marker: Boolean = true,
) : VirtualAccountStatusValueDM
@NameLabel("not_created")
data class NotCreated(
@Json(name = "not_created") val marker: Boolean = true,
) : VirtualAccountStatusValueDM
@NameLabel("kyc_status")
data class UnderReview(
@Json(name = "kyc_status") val kycStatus: KycStatus,
@Json(name = "customer_id") val customerId: String,
) : VirtualAccountStatusValueDM
@NameLabel("provisioning")
data class Provisioning(
@Json(name = "provisioning") val marker: Boolean = true,
) : VirtualAccountStatusValueDM
@NameLabel("country_not_supported")
data class CountryNotSupported(
@Json(name = "country_not_supported") val marker: Boolean = true,
) : VirtualAccountStatusValueDM
@NameLabel("active_account")
data class ActiveAccount(
@Json(name = "active_account") val marker: Boolean = true,
@Json(name = "customer_id") val customerId: String,
@Json(name = "currency_code") val currencyCode: String,
@Json(name = "deposit_address") val depositAddress: String?,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
@Json(name = "fiat_rate") val fiatRate: BigDecimal?,
@Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal,
) : VirtualAccountStatusValueDM
@JsonClass(generateAdapter = true)
data class FiatBalanceDM(
@Json(name = "available_balance") val availableBalance: BigDecimal,
@Json(name = "currency") val currency: String,
)
@JsonClass(generateAdapter = true)
data class CryptoBalanceDM(
@Json(name = "id") val id: String,
@Json(name = "chain_id") val chainId: Long,
@Json(name = "deposit_address") val depositAddress: String,
@Json(name = "token_contract_address") val tokenContractAddress: String,
@Json(name = "balance") val balance: BigDecimal,
)
}

View file

@ -1,7 +1,9 @@
package com.tangem.datasource.utils
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
@ -22,11 +24,15 @@ private const val JSON_INDENT_SPACES = 4
* Interceptor for save network requests and responses logs
*
* @property appLogsStore app logs store
* @property sensitiveUrlMasker masker for sensitive data in URLs
* @property shouldCheckResponseBodySize whether to skip logging large response bodies
*
[REDACTED_AUTHOR]
*/
class NetworkLogsSaveInterceptor(
private val appLogsStore: AppLogsStore,
private val sensitiveUrlMasker: SensitiveUrlMasker? = null,
private val shouldCheckResponseBodySize: Boolean = false,
) : Interceptor {
@Throws(IOException::class)
@ -65,7 +71,7 @@ class NetworkLogsSaveInterceptor(
val connection = chain.connection()
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
saveLogMessage("--> ${request.method} ${request.url}$connectionProtocol\n")
saveLogMessage("--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n")
}
private fun logRequestMessage(chain: Interceptor.Chain, request: Request) {
@ -73,7 +79,7 @@ class NetworkLogsSaveInterceptor(
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
saveLogMessage(
"--> ${request.method} ${request.url}$connectionProtocol\n",
"--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n",
createRequestEndMessage(request),
)
}
@ -110,7 +116,7 @@ class NetworkLogsSaveInterceptor(
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
saveLogMessage(
"<-- ${response.code}",
" ${response.request.url} (${tookMs}ms)\n",
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
)
}
@ -123,39 +129,45 @@ class NetworkLogsSaveInterceptor(
"<-- END HTTP"
} else if (bodyHasUnknownEncoding(response.headers)) {
"<-- END HTTP (encoded body omitted)"
} else if (shouldCheckResponseBodySize && contentLength > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
"Response size too large: $contentLength bytes \n<-- END HTTP"
} else {
val source = responseBody.source()
source.request(Long.MAX_VALUE)
var buffer = source.buffer
var gzippedLength: Long? = null
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
gzippedLength = buffer.size
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (!buffer.isProbablyUtf8()) {
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
if (shouldCheckResponseBodySize && buffer.size > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
"Response size too large: ${buffer.size} bytes \n<-- END HTTP"
} else {
val json = if (contentLength != 0L) {
buffer.clone().readString(charset).beautifyJson()
} else {
""
var gzippedLength: Long? = null
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
gzippedLength = buffer.size
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
val end = if (gzippedLength != null) {
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
} else {
"<-- END HTTP (${buffer.size}-byte body)"
}
val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
"$json\n$end"
if (!buffer.isProbablyUtf8()) {
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
} else {
val json = if (contentLength != 0L) {
buffer.clone().readString(charset).beautifyJson()
} else {
""
}
val end = if (gzippedLength != null) {
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
} else {
"<-- END HTTP (${buffer.size}-byte body)"
}
"$json\n$end"
}
}
}
@ -166,12 +178,16 @@ class NetworkLogsSaveInterceptor(
saveLogMessage(
"<-- ${response.code}",
spaceBeforeResponseMessage,
response.message,
" ${response.request.url} (${tookMs}ms)\n",
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
message,
)
}
private fun HttpUrl.maskSensitiveInfo(): String {
val url = toString()
return sensitiveUrlMasker?.mask(url) ?: url
}
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
val contentEncoding = headers["Content-Encoding"] ?: return false
return !contentEncoding.equals("identity", ignoreCase = true) &&
@ -231,6 +247,9 @@ class NetworkLogsSaveInterceptor(
}
private companion object {
const val WRITE_LOG_THRESHOLD_BYTES_SIZE = 2_048_000L
/**
* List of URLs (host + path) for which logging is restricted
*/

View file

@ -14,21 +14,40 @@ class WireMockRedirectInterceptor : Interceptor {
val request = chain.request()
val url = request.url.toString()
val host = request.url.host
val sanitizedOverride = override.trimEnd('/')
if (url.contains(WIREMOCK_REMOTE_URL)) {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, override.trimEnd('/'))
if (host == WIREMOCK_REMOTE_HOST) {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride)
TangemLogger.d("WireMockRedirect: $url -> $newUrl")
val newRequest = request.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
return chain.proceed(request.newBuilder().url(newUrl).build())
}
if (host in REDIRECTABLE_THIRD_PARTY_HOSTS) {
val newUrl = url.replace("https://$host", "$sanitizedOverride/$host")
TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl")
return chain.proceed(request.newBuilder().url(newUrl).build())
}
return chain.proceed(request)
}
companion object {
private const val WIREMOCK_REMOTE_URL = "[REDACTED_ENV_URL]"
private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com"
private const val WIREMOCK_REMOTE_URL = "https://$WIREMOCK_REMOTE_HOST"
/**
* Upstream hosts that have no other override knob and should be funnelled into WireMock
* when [overriddenBaseUrl] is set. Each matched URL becomes `<override>/<host>/<original-path>`,
* so mock mappings should live under that host-prefixed path in tangem-api-mocks. Matching
* is done against the request's parsed host (exact equality) substring matching would
* incorrectly redirect look-alikes such as `deep-index.moralis.io.evil.example`.
*/
private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf(
"deep-index.moralis.io",
"solana-gateway.moralis.io",
"api.etherscan.io",
)
/**
* Override base URL for WireMock requests.

View file

@ -88,6 +88,7 @@ class ApiConfigTest {
appInfoProvider = mockk(),
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}

View file

@ -130,6 +130,7 @@ internal class ProdApiConfigsManagerTest {
appInfoProvider = appInfoProvider,
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}
@ -148,9 +149,35 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.News -> createNewsModel()
ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel()
ApiConfig.ID.SurveySparrow -> createSurveySparrowModel()
ApiConfig.ID.Auth -> createAuthModel()
}
}
private fun createAuthModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return TestModel(
id = ApiConfig.ID.Auth,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = when (environment) {
ApiEnvironment.PROD -> "https://authentication.tangem.org/"
else -> "[REDACTED_ENV_URL]"
},
headers = emptyMap(),
),
)
}
private fun createExpressModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
@ -294,6 +321,7 @@ internal class ProdApiConfigsManagerTest {
private fun createGaslessTxServiceModel(): TestModel {
val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK to "[REDACTED_ENV_URL]"
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV to "[REDACTED_ENV_URL]"
INTERNAL_BUILD_TYPE,

View file

@ -14,7 +14,7 @@ import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -5,7 +5,7 @@ import com.google.common.truth.Truth
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
import java.io.IOException
/**

View file

@ -0,0 +1,107 @@
package com.tangem.datasource.local.card
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
* Tests Moshi serialization/deserialization of [UsedCardInfo].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class UsedCardInfoSerializationTest {
private val adapter = Moshi.Builder().build().adapter(UsedCardInfo::class.java)
@Test
fun `GIVEN full model WHEN toJson THEN all fields serialized in declaration order`() {
// Arrange
val model = UsedCardInfo(
cardId = "card-1",
isScanned = true,
isActivationStarted = true,
isActivationFinished = false,
hasBackupError = true,
)
// Act
val json = adapter.toJson(model)
// Assert
assertThat(json).isEqualTo(
"""{"cardId":"card-1","isScanned":true,"isActivationStarted":true,""" +
""""isActivationFinished":false,"hasBackupError":true}""",
)
}
@Test
fun `GIVEN full json WHEN fromJson THEN model fully populated`() {
// Arrange
val json = """{"cardId":"card-2","isScanned":false,"isActivationStarted":true,""" +
""""isActivationFinished":true,"hasBackupError":false}"""
// Act
val result = adapter.fromJson(json)
// Assert
assertThat(result).isEqualTo(
UsedCardInfo(
cardId = "card-2",
isScanned = false,
isActivationStarted = true,
isActivationFinished = true,
hasBackupError = false,
),
)
}
@Test
fun `GIVEN json with only cardId WHEN fromJson THEN boolean fields fall back to defaults`() {
// Arrange
val json = """{"cardId":"card-3"}"""
// Act
val result = adapter.fromJson(json)
// Assert
assertThat(result).isEqualTo(UsedCardInfo(cardId = "card-3"))
}
@Test
fun `GIVEN json without cardId WHEN fromJson THEN throws`() {
// Arrange
val json = """{"isScanned":true}"""
// Act
val error = runCatching { adapter.fromJson(json) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(JsonDataException::class.java)
}
@ParameterizedTest
@ProvideTestModels
fun roundTrip(model: UsedCardInfo) {
// Act
val restored = adapter.fromJson(adapter.toJson(model))
// Assert
assertThat(restored).isEqualTo(model)
}
private fun provideTestModels() = listOf(
UsedCardInfo(cardId = "default-only"),
UsedCardInfo(
cardId = "all-true",
isScanned = true,
isActivationStarted = true,
isActivationFinished = true,
hasBackupError = true,
),
UsedCardInfo(cardId = "activation-in-progress", isScanned = true, isActivationStarted = true),
UsedCardInfo(cardId = "backup-error", hasBackupError = true),
)
}

View file

@ -0,0 +1,276 @@
package com.tangem.datasource.local.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressHistoryConverterTest {
@Test
fun `GIVEN exchange item WHEN toEntity THEN all transaction fields are mapped`() {
// GIVEN
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// 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)
Truth.assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId)
Truth.assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
Truth.assertThat(entity.refundAddress).isEqualTo(item.refundAddress)
Truth.assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId)
Truth.assertThat(entity.rateType).isEqualTo(item.rateType)
Truth.assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
Truth.assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
Truth.assertThat(entity.payinHash).isEqualTo(item.payinHash)
Truth.assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
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.payTill).isEqualTo(item.payTill)
Truth.assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
}
@Test
fun `GIVEN exchange item WHEN toEntity THEN status is stored as raw string`() {
// GIVEN
val item = createExchangeItem(status = "finished")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.status).isEqualTo("finished")
}
@Test
fun `GIVEN exchange item WHEN toEntity THEN from and to assets are mapped`() {
// GIVEN
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
Truth.assertThat(entity.from.network).isEqualTo(item.fromNetwork)
Truth.assertThat(entity.from.decimals).isEqualTo(item.fromDecimals)
Truth.assertThat(entity.from.amount).isEqualTo(item.fromAmount)
// `from` asset never carries an actual amount
Truth.assertThat(entity.from.actualAmount).isNull()
Truth.assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
Truth.assertThat(entity.to.network).isEqualTo(item.toNetwork)
Truth.assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
Truth.assertThat(entity.to.amount).isEqualTo(item.toAmount)
Truth.assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount)
}
@Test
fun `GIVEN exchange item with null optional fields WHEN toEntity THEN nulls are preserved`() {
// GIVEN
val item = createExchangeItem(
payinExtraId = null,
refundAddress = null,
refundExtraId = null,
externalTxId = null,
externalTxUrl = null,
payinHash = null,
payoutHash = null,
refundNetwork = null,
refundContractAddress = null,
payTill = null,
averageDuration = null,
toActualAmount = null,
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.payinExtraId).isNull()
Truth.assertThat(entity.refundAddress).isNull()
Truth.assertThat(entity.refundExtraId).isNull()
Truth.assertThat(entity.externalTxId).isNull()
Truth.assertThat(entity.externalTxUrl).isNull()
Truth.assertThat(entity.payinHash).isNull()
Truth.assertThat(entity.payoutHash).isNull()
Truth.assertThat(entity.refundNetwork).isNull()
Truth.assertThat(entity.refundContractAddress).isNull()
Truth.assertThat(entity.payTill).isNull()
Truth.assertThat(entity.averageDuration).isNull()
Truth.assertThat(entity.to.actualAmount).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)
// 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)
Truth.assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
Truth.assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
Truth.assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
// todo txHistory uncomment
// Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.fromCurrencyCode).isEqualTo(item.fromCurrencyCode)
Truth.assertThat(entity.fromAmount).isEqualTo(item.fromAmount)
Truth.assertThat(entity.fromPrecision).isEqualTo(item.fromPrecision)
Truth.assertThat(entity.paymentMethod).isEqualTo(item.paymentMethod)
Truth.assertThat(entity.countryCode).isEqualTo(item.countryCode)
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN status is stored as raw string`() {
// GIVEN
val item = createOnrampItem(status = "waiting-for-payment")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.status).isEqualTo("waiting-for-payment")
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN to asset is mapped`() {
// GIVEN
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
Truth.assertThat(entity.to.network).isEqualTo(item.toNetwork)
Truth.assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
Truth.assertThat(entity.to.amount).isEqualTo(item.toAmount)
Truth.assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount)
}
@Test
fun `GIVEN onramp item with null optional fields WHEN toEntity THEN nulls are preserved`() {
// GIVEN
val item = createOnrampItem(
failReason = null,
externalTxId = null,
externalTxUrl = null,
payoutHash = null,
toAmount = null,
toActualAmount = null,
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.failReason).isNull()
Truth.assertThat(entity.externalTxId).isNull()
Truth.assertThat(entity.externalTxUrl).isNull()
Truth.assertThat(entity.payoutHash).isNull()
Truth.assertThat(entity.to.amount).isNull()
Truth.assertThat(entity.to.actualAmount).isNull()
}
private fun createExchangeItem(
status: String = "waiting",
payinExtraId: String? = "payin-extra",
refundAddress: String? = "refund-address",
refundExtraId: String? = "refund-extra",
externalTxId: String? = "external-tx-id",
externalTxUrl: String? = "https://provider.example/tx",
payinHash: String? = "payin-hash",
payoutHash: String? = "payout-hash",
refundNetwork: String? = "ethereum",
refundContractAddress: String? = "0xrefund",
payTill: String? = "2026-06-01T00:10:00Z",
averageDuration: Long? = 600L,
toActualAmount: String? = "0.99",
) = ExchangeItemResponse(
txId = "exchange-tx-1",
providerId = "changelly",
fromAddress = "0xfrom",
payinAddress = "0xpayin",
payinExtraId = payinExtraId,
payoutAddress = "0xpayout",
refundAddress = refundAddress,
refundExtraId = refundExtraId,
rateType = "float",
status = status,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = "2026-06-01T00:00:00Z",
// todo txHistory uncomment
// updatedAt = "2026-06-01T00:05:00Z",
payTill = payTill,
averageDuration = averageDuration,
fromContractAddress = "0xfromContract",
fromNetwork = "ethereum",
fromDecimals = 18,
fromAmount = "1.0",
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = "1.0",
toActualAmount = toActualAmount,
)
private fun createOnrampItem(
status: String = "waiting-for-payment",
failReason: String? = "fail-reason",
externalTxId: String? = "external-tx-id",
externalTxUrl: String? = "https://provider.example/tx",
payoutHash: String? = "payout-hash",
toAmount: String? = "0.001",
toActualAmount: String? = "0.00099",
) = OnrampItemResponse(
txId = "onramp-tx-1",
providerId = "mercuryo",
payoutAddress = "0xpayout",
status = status,
failReason = failReason,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = "2026-06-01T00:00:00Z",
// todo txHistory uncomment
// updatedAt = "2026-06-01T00:05:00Z",
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = toAmount,
toActualAmount = toActualAmount,
paymentMethod = "card",
countryCode = "US",
)
private companion object {
const val OWNER_ADDRESS = "0xowner"
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.datasource.local.logs
import com.google.common.truth.Truth
import com.tangem.datasource.local.logs.SensitiveUrlMasker.Companion.MASKED_VALUE
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SensitiveUrlMaskerTest {
@ParameterizedTest
@ProvideTestModels
fun mask(model: TestModel) {
// Arrange
val masker = SensitiveUrlMasker(model.sensitiveValues)
// Act
val actual = masker.mask(model.input)
// Assert
Truth.assertThat(actual).isEqualTo(model.expected)
}
@Test
fun `mask returns url unchanged when no sensitive values provided`() {
// Arrange
val masker = SensitiveUrlMasker(emptyList())
val url = "https://api.tangem.com/v1/cards/abc123"
// Act
val actual = masker.mask(url)
// Assert
Truth.assertThat(actual).isEqualTo(url)
}
@Test
fun `constructor deduplicates input values`() {
// Arrange — same secret repeated; if no dedup, replace would be invoked twice
// (idempotent on already-masked string, but we assert behavior is identical
// to a single-value masker as a smoke-check)
val withDuplicates = SensitiveUrlMasker(listOf("secret123", "secret123", "secret123"))
val withSingle = SensitiveUrlMasker(listOf("secret123"))
val url = "https://api.tangem.com/?key=secret123"
// Act
val withDup = withDuplicates.mask(url)
val withSingleResult = withSingle.mask(url)
// Assert
Truth.assertThat(withDup).isEqualTo(withSingleResult)
Truth.assertThat(withDup).isEqualTo("https://api.tangem.com/?key=$MASKED_VALUE")
}
private fun provideTestModels() = listOf(
TestModel(
input = "https://api.tangem.com/?key=secret123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/?a=alpha&b=beta",
sensitiveValues = listOf("alpha", "beta"),
expected = "https://api.tangem.com/?a=$MASKED_VALUE&b=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/?key=SECRET123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/v1/balance",
sensitiveValues = listOf("notInUrl"),
expected = "https://api.tangem.com/v1/balance",
),
TestModel(
input = "https://api.tangem.com/?key=secret123&other=secret123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE&other=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/v1/cards",
sensitiveValues = emptyList(),
expected = "https://api.tangem.com/v1/cards",
),
// Regression: when one value is a prefix of another, the longer one must be masked first
// regardless of input order, otherwise the suffix leaks (e.g. "my-node-prod" -> "******-prod").
TestModel(
input = "https://my-node-prod.example.com/v1",
sensitiveValues = listOf("my-node", "my-node-prod"),
expected = "https://$MASKED_VALUE.example.com/v1",
),
TestModel(
input = "https://my-node-prod.example.com/v1",
sensitiveValues = listOf("my-node-prod", "my-node"),
expected = "https://$MASKED_VALUE.example.com/v1",
),
)
data class TestModel(
val input: String,
val sensitiveValues: List<String>,
val expected: String,
)
}

View file

@ -6,7 +6,7 @@ import com.squareup.moshi.adapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.datasource.api.common.adapter.BigDecimalAdapter
import dev.onenowy.moshipolymorphicadapter.NamePolymorphicAdapterFactory
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
@ -42,10 +42,12 @@ class NetworkStatusDMSerializationTest {
{ "value": "0x123456", "type": "primary" },
{ "value": "0xabcdef", "type": "secondary" }
],
"amounts": { "ETH": "1.2345" },
"yield_supply_statuses": {
"ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
}
"amounts": [
{ "id": { "value": "ethereum" }, "amount": "1.2345" }
],
"yield_supply_statuses": [
{ "id": { "value": "ethereum" }, "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
]
}
""".trimIndent()
@ -129,10 +131,12 @@ class NetworkStatusDMSerializationTest {
{ "value": "0x123456", "type": "primary" },
{ "value": "0xabcdef", "type": "secondary" }
],
"amounts": { "ETH": "1.2345" },
"yield_supply_statuses": {
"ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
}
"amounts": [
{ "id": { "value": "ethereum" }, "amount": "1.2345" }
],
"yield_supply_statuses": [
{ "id": { "value": "ethereum" }, "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
]
}
""".stripJsonWhitespace()

View file

@ -0,0 +1,74 @@
package com.tangem.datasource.local.visa.entity
import com.google.common.truth.Truth
import com.squareup.moshi.adapter
import com.tangem.datasource.di.MoshiModule
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Verifies that the network Moshi (see [MoshiModule.provideNetworkMoshi]) resolves and round-trips the
* polymorphic [VirtualAccountStatusValueDM] adapter.
*
* Guards against the missing `NamePolymorphicAdapterFactory` registration that caused a runtime
* `ClassNotFoundException: ...VirtualAccountStatusValueDMJsonAdapter` (the adapter is registered manually,
* not generated).
*/
class VirtualAccountStatusValueDMSerializationTest {
@OptIn(ExperimentalStdlibApi::class)
private val adapter = MoshiModule().provideNetworkMoshi().adapter<VirtualAccountStatusValueDM>()
@Test
fun `round-trip NotCreated`() {
val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.NotCreated()
val restored = adapter.fromJson(adapter.toJson(model))
Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.NotCreated::class.java)
}
@Test
fun `round-trip Provisioning`() {
val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.Provisioning()
val restored = adapter.fromJson(adapter.toJson(model))
Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.Provisioning::class.java)
}
@Test
fun `round-trip CountryNotSupported`() {
val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.CountryNotSupported()
val restored = adapter.fromJson(adapter.toJson(model))
Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.CountryNotSupported::class.java)
}
@Test
fun `round-trip ActiveAccount`() {
val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.ActiveAccount(
customerId = "cust-1",
currencyCode = "USD",
depositAddress = "0xabc",
fiatBalance = VirtualAccountStatusValueDM.FiatBalanceDM(
availableBalance = BigDecimal("101.56"),
currency = "USD",
),
cryptoBalance = VirtualAccountStatusValueDM.CryptoBalanceDM(
id = "usd-coin",
chainId = 137L,
depositAddress = "0xabc",
tokenContractAddress = "0xdef",
balance = BigDecimal("101.56"),
),
fiatRate = BigDecimal("0.95"),
availableForWithdrawal = BigDecimal("100.00"),
)
val restored = adapter.fromJson(adapter.toJson(model))
Truth.assertThat(restored).isEqualTo(model)
}
}

View file

@ -86,11 +86,6 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
.joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') }
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
android {
namespace = "com.tangem.core.ui"
@ -171,10 +166,7 @@ dependencies {
}
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testRuntimeOnly(deps.test.junit5.vintage.engine)
}

View file

@ -10,7 +10,6 @@
<ID>MultilineLambdaItParameter:Actions.kt${ ActionButtonContent( config = config, text = { textColor -&gt; Text(text = config.text, textColor = textColor) }, modifier = it.padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing24, ), ) }</ID>
<ID>MultilineLambdaItParameter:TangemDropdownMenu.kt${ if (it) { // Menu is expanded. 1f } else { // Menu is dismissed. 0.8f } }</ID>
<ID>MultilineLambdaItParameter:TangemDropdownMenu.kt${ if (it) { // Menu is expanded. 1f } else { // Menu is dismissed. 0f } }</ID>
<ID>NestedScopeFunctions:MessageBottomSheetUMV2.kt$apply(init)</ID>
<ID>NestedScopeFunctions:Shadow.kt$apply { isDither = true isAntiAlias = true setShadowLayer( radiusPx, offset.x.toPx(), offset.y.toPx(), color.toArgb(), ) }</ID>
<ID>NoNameShadowing:PinTextField.kt$value</ID>
<ID>NoNameShadowing:SimpleTextField.kt$textStyle</ID>

@ -1 +1 @@
Subproject commit 06d801c92ac499d787093c30783e9ccb1f7e43dc
Subproject commit 42aac70c1d2d0d636470fa476703cab353010bba

View file

@ -2,4 +2,6 @@ package com.tangem.core.ui
interface DesignFeatureToggles {
val isRedesignEnabled: Boolean
val isWarningsRefactoringEnabled: Boolean
}

View file

@ -10,7 +10,6 @@ import androidx.compose.material3.ButtonColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
@ -190,7 +189,6 @@ fun PrimaryButtonIconStart(
enabled: Boolean = true,
tint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -205,7 +203,6 @@ fun PrimaryButtonIconStart(
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
// endregion PrimaryButton
@ -219,7 +216,6 @@ fun SecondaryButton(
showProgress: Boolean = false,
enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -230,7 +226,6 @@ fun SecondaryButton(
enabled = enabled,
showProgress = showProgress,
size = size,
shape = shape,
textStyle = TangemTheme.typography.subtitle1,
)
}
@ -248,7 +243,6 @@ fun SecondaryButtonIconEnd(
enabled: Boolean = true,
tint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -263,7 +257,6 @@ fun SecondaryButtonIconEnd(
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
@ -280,7 +273,6 @@ fun SecondaryButtonIconStart(
enabled: Boolean = true,
iconTint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -295,7 +287,6 @@ fun SecondaryButtonIconStart(
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
// endregion SecondaryButton

View file

@ -245,7 +245,8 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) {
OutlineTextField(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing24)
.fillMaxWidth(),
.fillMaxWidth()
.testTag(BaseDialogTestTags.TEXT_INPUT_FIELD),
value = type.value,
label = type.params.label,
placeholder = type.params.placeholder,

View file

@ -93,6 +93,39 @@ fun Modifier.topFade(
solidStop = solidStop,
)
/**
* Draws a vertical gradient fade over the top edge with custom [colorStops].
*
* Stops are defined relative to [height] (0f = top, 1f = bottom of the fade region).
*
* Note: the gradient is drawn over the entire content area, so the last color stop's color
* extends below the fade region down to the bottom. Pass [Color.Transparent] as the last stop
* (or a color matching the underlying content) to avoid covering the area outside the fade.
*/
@Composable
fun Modifier.topFade(height: Dp, vararg colorStops: Pair<Float, Color>): Modifier = composed {
drawWithContent {
drawContent()
if (this.size.height <= 0f) return@drawWithContent
val fraction = (height.toPx() / this.size.height).coerceIn(0f, 1f)
if (fraction <= 0f) return@drawWithContent
val (start, end) = this.size.getFadeOffsets(FadePosition.TOP)
drawRect(
brush = Brush.linearGradient(
colorStops = colorStops
.map { (stop, color) -> stop.coerceIn(0f, 1f) * fraction to color }
.toTypedArray(),
start = start,
end = end,
),
size = this.size,
)
}
}
enum class FadePosition {
TOP, BOTTOM, LEFT, RIGHT
}

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -28,9 +29,10 @@ import androidx.compose.ui.unit.sp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
enum class AccountIconSize {
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall
}
/**
@ -129,6 +131,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
AccountIconSize.Small -> TangemTheme.typography.subtitle2
AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1
AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28
AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11
}
val textSize by animateFloatAsState(
@ -148,6 +151,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
text = char.uppercase(),
style = textStyle.copy(fontSize = textSize.sp),
color = TangemTheme.colors.text.constantWhite,
textAlign = TextAlign.Center,
)
}
}
@ -161,6 +165,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) {
AccountIconSize.Small -> 12.dp
AccountIconSize.ExtraSmall -> 8.dp
AccountIconSize.RedesignedDefault -> 20.dp
AccountIconSize.RedesignExtraSmall -> 8.dp
}
fun AccountIconSize.toBoxSize(): Dp = when (this) {
@ -170,6 +175,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) {
AccountIconSize.Small -> 20.dp
AccountIconSize.ExtraSmall -> 14.dp
AccountIconSize.RedesignedDefault -> 40.dp
AccountIconSize.RedesignExtraSmall -> 16.dp
}
private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
@ -179,6 +185,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
AccountIconSize.Small -> 6.dp
AccountIconSize.ExtraSmall -> 4.dp
AccountIconSize.RedesignedDefault -> 12.dp
AccountIconSize.RedesignExtraSmall -> 6.dp
}
@Preview(showBackground = true)
@ -194,6 +201,19 @@ private fun Preview() {
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewRedesigned() {
TangemThemePreviewRedesign {
Row(
modifier = Modifier.background(TangemTheme.colors.background.primary),
) {
Sample()
}
}
}
@Composable
private fun Sample() {
var sizeState by remember { mutableStateOf(AccountIconSize.ExtraSmall) }
@ -206,8 +226,9 @@ private fun Sample() {
AccountIconSize.Large -> AccountIconSize.Medium
AccountIconSize.Medium -> AccountIconSize.Small
AccountIconSize.Small -> AccountIconSize.ExtraSmall
AccountIconSize.ExtraSmall -> AccountIconSize.Default
AccountIconSize.RedesignedDefault -> AccountIconSize.Large
AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault
AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall
AccountIconSize.RedesignExtraSmall -> AccountIconSize.Default
}
}) { Text("Change") }
@ -225,5 +246,6 @@ private fun Sample() {
AccountCharIcon(char = 'M', color = Color.Magenta, size = AccountIconSize.Medium)
AccountCharIcon(char = 'S', color = Color.DarkGray, size = AccountIconSize.Small)
AccountCharIcon(char = 'E', color = Color.Green, size = AccountIconSize.ExtraSmall)
AccountCharIcon(char = 'D', color = Color.LightGray, size = AccountIconSize.RedesignExtraSmall)
}
}

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -39,6 +40,7 @@ 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.test.BaseBottomSheetTestTags
import com.tangem.core.ui.utils.WindowInsetsZero
/**
@ -253,7 +255,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicBottomSheet(
Column(
modifier = contentModifier
.background(containerColor)
.heightIn(max = maxHeight),
.heightIn(max = maxHeight)
.testTag(BaseBottomSheetTestTags.CONTAINER),
) {
Box(modifier = Modifier.fillMaxWidth()) {
title(model)

View file

@ -1,305 +1,23 @@
package com.tangem.core.ui.components.bottomsheets.message
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM.Button.IconOrder
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.icons.HighlightedIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.WarningBottomSheetTestTags
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import com.tangem.core.ui.res.LocalRedesignEnabled
@Composable
fun MessageBottomSheet(state: MessageBottomSheetUM, onDismissRequest: () -> Unit) {
val stateWithOnDismiss = remember(state) {
state.copy(
onDismissRequest = {
state.onDismissRequest.invoke()
onDismissRequest()
},
)
if (LocalRedesignEnabled.current) {
MessageBottomSheetV2(state, onDismissRequest)
} else {
MessageBottomSheetV1(state, onDismissRequest)
}
val config = TangemBottomSheetConfig(
isShown = true,
content = stateWithOnDismiss,
onDismissRequest = stateWithOnDismiss.onDismissRequest,
)
TangemModalBottomSheet(
config = config,
title = {
TangemModalBottomSheetTitle(
endIconRes = R.drawable.ic_close_24,
onEndClick = stateWithOnDismiss.onDismissRequest,
)
},
content = { content: MessageBottomSheetUM -> MessageBottomSheetContent(content) },
)
}
@Composable
fun MessageBottomSheetContent(state: MessageBottomSheetUM, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
state.elements.fastForEach { element ->
when (element) {
is MessageBottomSheetUM.InfoBlock -> {
ContentContainer(
modifier = Modifier
.heightIn(min = TangemTheme.dimens.size180)
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(bottom = 32.dp),
state = element,
)
}
else -> Unit
}
}
ButtonsContainer(
modifier = Modifier.fillMaxWidth(),
closeScope = state.closeScope,
buttons = state.elements.filterIsInstance<MessageBottomSheetUM.Button>().toPersistentList(),
)
}
}
@Composable
private fun ContentContainer(state: MessageBottomSheetUM.InfoBlock, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
BottomSheetIconContainer(state.icon, state.iconImage)
state.title?.let { title ->
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing24)
.testTag(WarningBottomSheetTestTags.TITLE),
text = title.resolveReference(),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
}
state.body?.let { body ->
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing8)
.testTag(WarningBottomSheetTestTags.MESSAGE),
text = body.resolveAnnotatedReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
state.chip?.let { chip ->
BottomSheetChip(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing16),
chip = chip,
)
}
}
}
@Composable
private fun BottomSheetIconContainer(
icon: MessageBottomSheetUM.Icon?,
iconImage: MessageBottomSheetUM.IconImage?,
modifier: Modifier = Modifier,
) {
if (icon != null) {
BottomSheetIcon(icon, modifier)
} else if (iconImage != null) {
Image(
modifier = modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape),
painter = painterResource(id = iconImage.res),
contentDescription = null,
)
}
}
@Composable
private fun BottomSheetIcon(icon: MessageBottomSheetUM.Icon, modifier: Modifier = Modifier) {
val tint = when (icon.type) {
MessageBottomSheetUM.Icon.Type.Unspecified -> Color.Unspecified
MessageBottomSheetUM.Icon.Type.Accent -> TangemTheme.colors.icon.accent
MessageBottomSheetUM.Icon.Type.Informative -> TangemTheme.colors.icon.informative
MessageBottomSheetUM.Icon.Type.Attention -> TangemTheme.colors.icon.attention
MessageBottomSheetUM.Icon.Type.Warning -> TangemTheme.colors.icon.warning
}
val backgroundColor = when (icon.backgroundType) {
MessageBottomSheetUM.Icon.BackgroundType.Unspecified -> TangemTheme.colors.icon.informative
MessageBottomSheetUM.Icon.BackgroundType.SameAsTint -> tint
MessageBottomSheetUM.Icon.BackgroundType.Accent -> TangemTheme.colors.icon.accent
MessageBottomSheetUM.Icon.BackgroundType.Informative -> TangemTheme.colors.icon.informative
MessageBottomSheetUM.Icon.BackgroundType.Attention -> TangemTheme.colors.icon.attention
MessageBottomSheetUM.Icon.BackgroundType.Warning -> TangemTheme.colors.icon.warning
}
HighlightedIcon(
modifier = modifier,
icon = icon.res,
iconTint = tint,
backgroundColor = backgroundColor,
)
}
@Composable
private fun BottomSheetChip(chip: MessageBottomSheetUM.Chip, modifier: Modifier = Modifier) {
val color = when (chip.type) {
MessageBottomSheetUM.Chip.Type.Unspecified -> TangemTheme.colors.text.primary1
MessageBottomSheetUM.Chip.Type.Warning -> TangemTheme.colors.text.warning
}
Text(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
color = color.copy(alpha = 0.1F),
)
.padding(vertical = TangemTheme.dimens.spacing4, horizontal = TangemTheme.dimens.spacing12),
text = chip.text.resolveReference(),
style = TangemTheme.typography.caption1,
color = color,
)
}
@Suppress("LongMethod")
@Composable
private fun ButtonsContainer(
buttons: ImmutableList<MessageBottomSheetUM.Button>,
closeScope: MessageBottomSheetUM.CloseScope,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(all = TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
buttons.fastForEach { button ->
val icon = button.icon?.let { iconResId ->
when (button.iconOrder) {
IconOrder.Start -> TangemButtonIconPosition.Start(iconResId)
IconOrder.End -> TangemButtonIconPosition.End(iconResId)
}
} ?: TangemButtonIconPosition.None
TangemButton(
modifier = Modifier
.fillMaxWidth()
.testTag(
if (button.isPrimary) {
WarningBottomSheetTestTags.BUTTON_PRIMARY
} else {
WarningBottomSheetTestTags.BUTTON_SECONDARY
},
),
text = button.text?.resolveReference().orEmpty(),
icon = icon,
onClick = { button.onClick?.invoke(closeScope) },
colors = if (button.isPrimary) {
TangemButtonsDefaults.primaryButtonColors
} else {
TangemButtonsDefaults.secondaryButtonColors
},
enabled = true,
showProgress = false,
textStyle = TangemTheme.typography.subtitle1,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
MessageBottomSheet(
messageBottomSheetUM {
infoBlock {
icon(R.drawable.img_knight_shield_32) {
type = MessageBottomSheetUM.Icon.Type.Attention
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint
}
title = TextReference.Str("Title Title Title")
body = TextReference.Str("Body")
chip(text = TextReference.Str("Some chip information"))
}
primaryButton {
text = TextReference.Str("Test")
icon = R.drawable.ic_tangem_24
}
secondaryButton {
icon = R.drawable.ic_tangem_24
text = TextReference.Str("asdasd")
onClick {
closeBs()
}
}
},
onDismissRequest = {},
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview2() {
TangemThemePreview {
MessageBottomSheet(
messageBottomSheetUM {
infoBlock {
iconImage = MessageBottomSheetUM.IconImage(R.drawable.img_visa_notification)
title = TextReference.Str("Title Title Title")
body = TextReference.Str("Body")
chip(text = TextReference.Str("Some chip information"))
}
primaryButton {
text = TextReference.Str("Test")
icon = R.drawable.ic_tangem_24
}
secondaryButton {
icon = R.drawable.ic_tangem_24
text = TextReference.Str("asdasd")
onClick {
closeBs()
}
}
},
onDismissRequest = {},
)
if (LocalRedesignEnabled.current) {
MessageBottomSheetContentV2(state, modifier)
} else {
MessageBottomSheetContentV1(state, modifier)
}
}

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