Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-03 21:52:57 +03:00
commit a0c9ace219
1607 changed files with 40580 additions and 16776 deletions

View file

@ -131,6 +131,12 @@ sealed class AnalyticsParam {
override val token: String,
override val feeType: FeeType,
) : TxSentFrom("Send&Swap"), TxData
data class Earning(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
) : TxSentFrom("Earning"), TxData
}
sealed interface TxData {

View file

@ -19,12 +19,14 @@ sealed class Basic(
batch: String,
signInType: SignInType,
walletsCount: String,
isImported: Boolean,
hasBackup: Boolean?,
) : Basic(
event = "Signed in",
params = buildMap {
put(AnalyticsParam.CURRENCY, currency.value)
put(AnalyticsParam.BATCH, batch)
put("Wallet Type", if (isImported) "Seed Phrase" else "Seedless")
put("Sign in type", signInType.name)
put("Wallets Count", walletsCount)
if (hasBackup != null) {

View file

@ -28,11 +28,11 @@
"version": "5.21.0"
},
{
"name": "scroll",
"name": "zklink",
"version": "undefined"
},
{
"name": "zklink",
"name": "scroll",
"version": "undefined"
}
]

View file

@ -23,26 +23,14 @@
"name": "USEDESK_ENABLED",
"version": "undefined"
},
{
"name": "SEND_VIA_SWAP_ENABLED",
"version": "5.28.0"
},
{
"name": "SWAP_REDESIGN_ENABLED",
"version": "undefined"
},
{
"name": "SEND_REDESIGN_ENABLED",
"version": "5.28.0"
},
{
"name": "HOT_WALLET_ENABLED",
"version": "undefined"
},
{
"name": "NFT_SEND_REDESIGN_ENABLED",
"version": "5.28.0"
},
{
"name": "TANGEM_PAY_ENABLED",
"version": "undefined"
@ -53,7 +41,7 @@
},
{
"name": "YIELD_SUPPLY_FEATURE_ENABLED",
"version": "undefined"
"version": "5.30.0"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",

View file

@ -21,7 +21,7 @@ internal class DevFeatureTogglesManager(
private val featureTogglesLocalStorage: LocalTogglesStorage,
) : MutableFeatureTogglesManager {
private var fileFeatureTogglesMap: Map<String, Boolean> = getFileFeatureToggles()
private val fileFeatureTogglesMap: Map<String, Boolean> = getFileFeatureToggles()
private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
init {

View file

@ -23,14 +23,14 @@ internal class DefaultVersionProvider @Inject constructor(
private fun getVersionName(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager
.getPackageInfo(
context.packageName,
PackageManager.PackageInfoFlags.of(0),
)
.versionName!!
requireNotNull(
context
.packageManager
.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0))
.versionName,
)
} else {
context.packageManager.getPackageInfo(context.packageName, 0).versionName!!
requireNotNull(context.packageManager.getPackageInfo(context.packageName, 0).versionName)
}
}

View file

@ -40,6 +40,8 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.visa.models)
/** Tangem libraries */
implementation(tangemDeps.blockchain)

View file

@ -1,9 +1,11 @@
package com.tangem.datasource.api.common.blockaid
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionBulkScanRequest
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse
import com.tangem.datasource.api.common.blockaid.models.response.GasEstimationResponse
import com.tangem.datasource.api.common.blockaid.models.response.SolanaTransactionResponse
import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse
import retrofit2.http.Body
@ -19,4 +21,7 @@ interface BlockAidApi {
@POST("solana/message/scan")
suspend fun scanSolanaMessage(@Body request: SolanaTransactionScanRequest): SolanaTransactionResponse
@POST("evm/transaction-bulk/scan")
suspend fun scanEvmTransactionBulk(@Body request: EvmTransactionBulkScanRequest): List<GasEstimationResponse>
}

View file

@ -0,0 +1,7 @@
package com.tangem.datasource.api.common.blockaid.models.request
enum class BlockAidScanOptions(val value: String) {
Simulation("simulation"),
Validation("validation"),
GasEstimation("gas_estimation"),
}

View file

@ -14,9 +14,26 @@ data class EvmTransactionScanRequest(
@Json(name = "metadata") val metadata: TransactionMetadata,
)
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class EvmTransactionBulkScanRequest(
@Json(name = "chain") val chain: String,
@Json(name = "options") val options: List<String>,
@Json(name = "metadata") val metadata: TransactionMetadata,
@Json(name = "data") val data: List<Data>,
@Json(name = "aggregated") val aggregated: Boolean = false,
)
@JsonClass(generateAdapter = true)
data class RpcData(
@Json(name = "jsonrpc") val jsonrpc: String = "2.0",
@Json(name = "method") val method: String,
@Json(name = "params") val params: List<Map<String, String>>,
)
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "from") val from: String,
@Json(name = "to") val to: String,
@Json(name = "data") val data: String,
)

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GasEstimationResponse(
@Json(name = "gas_estimation") val gasEstimation: GasEstimationItem,
)
@JsonClass(generateAdapter = true)
data class GasEstimationItem(
@Json(name = "estimate") val estimate: String,
)

View file

@ -26,6 +26,7 @@ sealed class ApiConfig {
StakeKit,
TangemPay,
BlockAid,
YieldSupply,
}
private fun initializeId(): ID {
@ -35,6 +36,7 @@ sealed class ApiConfig {
is StakeKit -> ID.StakeKit
is TangemPay -> ID.TangemPay
is BlockAid -> ID.BlockAid
is YieldSupply -> ID.YieldSupply
}
}

View file

@ -16,6 +16,9 @@ enum class ApiEnvironment {
@Json(name = "DEV_2")
DEV_2,
@Json(name = "DEV_3")
DEV_3,
@Json(name = "STAGE")
STAGE,

View file

@ -28,6 +28,7 @@ internal class Express(
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createDev2Environment(),
createDev3Environment(),
createStageEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
@ -60,6 +61,12 @@ internal class Express(
headers = createHeaders(isProd = false),
)
private fun createDev3Environment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV_3,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(isProd = false),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "[REDACTED_ENV_URL]",

View file

@ -43,7 +43,7 @@ internal class TangemPay(
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.paera.com/bff/",
baseUrl = "https://api.us.paera.com/bff/",
headers = createHeaders(),
)

View file

@ -2,12 +2,15 @@ package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
/** TangemTech [ApiConfig] */
internal class TangemTech(
private val environmentConfigStorage: EnvironmentConfigStorage,
private val appVersionProvider: AppVersionProvider,
private val authProvider: AuthProvider,
private val appInfoProvider: AppInfoProvider,
@ -38,29 +41,42 @@ internal class TangemTech(
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
headers = createHeaders(ApiEnvironment.STAGE),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
headers = createHeaders(ApiEnvironment.MOCK),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/",
headers = createHeaders(),
headers = createHeaders(ApiEnvironment.PROD),
)
private fun createHeaders() = buildMap {
private fun createHeaders(apiEnvironment: ApiEnvironment) = buildMap {
put(key = "api-key", value = ProviderSuspend { getApiKey(apiEnvironment) })
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
putAll(from = RequestHeader.AuthenticationHeader(authProvider).values)
}
private fun getApiKey(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.MOCK,
ApiEnvironment.DEV,
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
-> environmentConfigStorage.getConfigSync().tangemApiKeyDev
ApiEnvironment.STAGE -> environmentConfigStorage.getConfigSync().tangemApiKeyStage
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().tangemApiKey
} ?: error("No tangem tech api config provided")
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
/** YieldSupply [ApiConfig] */
internal class YieldSupply(
private val environmentConfigStorage: EnvironmentConfigStorage,
private val appVersionProvider: AppVersionProvider,
private val authProvider: AuthProvider,
private val appInfoProvider: AppInfoProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createDevEnvironment(),
createStageEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
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 = "[REDACTED_ENV_URL]",
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(ApiEnvironment.STAGE),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(ApiEnvironment.MOCK),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://yield.tangem.org/",
headers = createHeaders(ApiEnvironment.PROD),
)
private fun createHeaders(apiEnvironment: ApiEnvironment) = buildMap {
put(key = "api-key", value = ProviderSuspend {
getApiKey(apiEnvironment)
})
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
putAll(from = RequestHeader.AuthenticationHeader(authProvider).values)
}
private fun getApiKey(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.MOCK,
ApiEnvironment.DEV,
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
-> environmentConfigStorage.getConfigSync().yieldModuleApiKeyDev
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().yieldModuleApiKey
} ?: error("No tangem tech api config provided")
}
}

View file

@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
interface ApiConfigsManager {
/** Flag that determines whether the manager is initialized */
val isInitialized: StateFlow<Boolean>
val initializedState: StateFlow<Boolean>
/** Initialize resources */
fun initialize()

View file

@ -26,27 +26,27 @@ internal class DevApiConfigsManager(
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
field = MutableStateFlow(value = getInitialConfigs())
override val isInitialized: StateFlow<Boolean>
field = MutableStateFlow(value = false)
override val initializedState: StateFlow<Boolean>
field = MutableStateFlow(value = false)
override fun initialize() {
isInitialized.value = false
initializedState.value = false
appPreferencesStore.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
.distinctUntilChanged()
.onEach { savedEnvironments ->
val apiConfigs = configs.value
configs.value = apiConfigs.mapValues {
val (config, currentEnvironment) = it
configs.value = apiConfigs.mapValues { entry ->
val (config, currentEnvironment) = entry
savedEnvironments[config.id.name] ?: currentEnvironment
}
if (!isInitialized.value) {
isInitialized.value = true
if (!initializedState.value) {
initializedState.value = true
}
notifyListeners(apiConfigs = apiConfigs, savedEnvironments = savedEnvironments)

View file

@ -22,9 +22,9 @@ internal class MockApiConfigsManager(
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
field = MutableStateFlow(value = getInitialConfigs())
override val isInitialized: StateFlow<Boolean> = MutableStateFlow(value = true)
override val initializedState: StateFlow<Boolean> = MutableStateFlow(value = true)
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@ -63,7 +63,7 @@ internal class MockApiConfigsManager(
super.addListener(listener)
configs
.map { it.entries.firstOrNull { it.key.id == listener.id } }
.map { map -> map.entries.firstOrNull { it.key.id == listener.id } }
.filterNotNull()
.onEach { (apiConfig, currentEnvironment) ->
listener.onChange(

View file

@ -20,7 +20,7 @@ abstract class MutableApiConfigsManager : ApiConfigsManager {
* These listeners are notified whenever an environment change occurs.
*/
protected val registerListeners: Set<ApiConfigEnvChangeListener>
field = mutableSetOf<ApiConfigEnvChangeListener>()
field = mutableSetOf<ApiConfigEnvChangeListener>()
/** Change api environment [environment] by [id] */
abstract suspend fun changeEnvironment(id: String, environment: ApiEnvironment)

View file

@ -15,7 +15,7 @@ internal class ProdApiConfigsManager(
private val apiConfigs: ApiConfigs,
) : ApiConfigsManager {
override val isInitialized: StateFlow<Boolean> = MutableStateFlow(value = true)
override val initializedState: StateFlow<Boolean> = MutableStateFlow(value = true)
override fun initialize() = Unit

View file

@ -72,14 +72,14 @@ sealed class ApiResponseError : Exception() {
/** Represents a network error, typically when there's no connectivity. */
@Suppress("UnusedPrivateMember")
data object NetworkException : ApiResponseError() {
private fun readResolve(): Any = NetworkException
class NetworkException : ApiResponseError() {
private fun readResolve(): Any = NetworkException()
}
/** Represents a timeout error, typically when the server takes too long to respond. */
@Suppress("UnusedPrivateMember")
data object TimeoutException : ApiResponseError() {
private fun readResolve(): Any = TimeoutException
class TimeoutException : ApiResponseError() {
private fun readResolve(): Any = TimeoutException()
}
/**
@ -87,5 +87,5 @@ sealed class ApiResponseError : Exception() {
*
* @property cause The exception that caused this error.
*/
data class UnknownException(override val cause: Throwable) : ApiResponseError()
class UnknownException(override val cause: Throwable) : ApiResponseError()
}

View file

@ -1,3 +1,3 @@
package com.tangem.datasource.api.common.response
const val IF_NONE_MATCH_HEADER = "IfNoneMatch"
const val ETAG_HEADER = "etag"

View file

@ -58,10 +58,10 @@ internal fun Throwable.toApiError(): ApiResponseError = when (this) {
is ConnectException,
is UnknownHostException,
is SSLHandshakeException,
-> ApiResponseError.NetworkException
-> ApiResponseError.NetworkException()
is TimeoutException,
is TimeoutCancellationException,
is SocketTimeoutException,
-> ApiResponseError.TimeoutException
-> ApiResponseError.TimeoutException()
else -> ApiResponseError.UnknownException(cause = this)
}

View file

@ -4,6 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class Asset(
@Json(name = "contractAddress")
val contractAddress: String,

View file

@ -56,6 +56,7 @@ data class TokenMarketInfoResponse(
)
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class Network(
@Json(name = "network_id")
val networkId: String,

View file

@ -4,6 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class OnrampCountryDTO(
@Json(name = "name")
val name: String,

View file

@ -7,8 +7,11 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
private const val TX_HISTORY_PAGING_DEFAULT_LIMIT = 20
@Suppress("TooManyFunctions")
interface TangemPayApi {
@ -107,6 +110,13 @@ interface TangemPayApi {
@Query("offset") offset: Int,
): ApiResponse<VisaTxHistoryResponse>
@GET("v1/customer/transactions")
suspend fun getTangemPayTxHistory(
@Header("Authorization") authHeader: String,
@Query("cursor") cursor: String?,
@Query("limit") limit: Int = TX_HISTORY_PAGING_DEFAULT_LIMIT,
): ApiResponse<TangemPayTxHistoryResponse>
@GET("v1/customer/kyc")
suspend fun getKycAccess(@Header("Authorization") authHeader: String): ApiResponse<KycAccessInfoResponse>
@ -115,4 +125,25 @@ interface TangemPayApi {
@POST("v1/deeplink/validate")
suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse<DeeplinkValidityResponse>
@GET("v1/order/{order_id}")
suspend fun getOrder(
@Header("Authorization") authHeader: String,
@Path("order_id") orderId: String,
): ApiResponse<OrderResponse>
@POST("v1/order")
suspend fun createOrder(
@Header("Authorization") authHeader: String,
@Body body: OrderRequest,
): ApiResponse<OrderResponse>
@GET("v1/customer/balance")
suspend fun getCardBalance(@Header("Authorization") authHeader: String): ApiResponse<CardBalanceResponse>
@POST("v1/customer/card/details")
suspend fun revealCardDetails(
@Header("Authorization") authHeader: String,
@Body body: CardDetailsRequest,
): ApiResponse<CardDetailsResponse>
}

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 CardDetailsRequest(
@Json(name = "session_id") val sessionId: 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 OrderRequest(
@Json(name = "wallet_address") val walletAddress: String,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
data class CardBalanceResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "currency") val currency: String,
@Json(name = "available_balance") val availableBalance: BigDecimal,
@Json(name = "credit_limit") val creditLimit: BigDecimal,
@Json(name = "pending_charges") val pendingCharges: BigDecimal,
@Json(name = "posted_charges") val postedCharges: BigDecimal,
@Json(name = "balance_due") val balanceDue: BigDecimal,
)
}

View file

@ -0,0 +1,28 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
data class CardDetailsResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "token") val token: String,
@Json(name = "expiration_month") val expirationMonth: String,
@Json(name = "expiration_year") val expirationYear: String,
@Json(name = "emboss_name") val embossName: String,
@Json(name = "card_type") val cardType: String,
@Json(name = "card_status") val cardStatus: String,
@Json(name = "card_number_end") val cardNumberEnd: String,
@Json(name = "pan") val pan: Secret,
@Json(name = "cvv") val cvv: Secret,
)
@JsonClass(generateAdapter = true)
data class Secret(
@Json(name = "secret") val secret: String,
@Json(name = "iv") val iv: String,
)
}

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class CustomerMeResponse(
@ -16,6 +17,9 @@ data class CustomerMeResponse(
@Json(name = "product_instance") val productInstance: ProductInstance?,
@Json(name = "payment_account") val paymentAccount: PaymentAccount?,
@Json(name = "kyc") val kyc: Kyc?,
@Json(name = "depositAddress") val depositAddress: String?,
@Json(name = "card") val card: Card?,
@Json(name = "balance") val balance: Balance?,
)
@JsonClass(generateAdapter = true)
@ -45,4 +49,25 @@ data class CustomerMeResponse(
@Json(name = "review_answer") val reviewAnswer: String,
@Json(name = "created_at") val createdAt: String,
)
@JsonClass(generateAdapter = true)
data class Card(
@Json(name = "token") val token: String,
@Json(name = "expiration_month") val expirationMonth: Int,
@Json(name = "expiration_year") val expirationYear: Int,
@Json(name = "emboss_name") val embossName: String,
@Json(name = "card_type") val cardType: String,
@Json(name = "card_status") val cardStatus: String,
@Json(name = "card_number_end") val cardNumberEnd: String,
)
@JsonClass(generateAdapter = true)
data class Balance(
@Json(name = "currency") val currency: String,
@Json(name = "available_balance") val availableBalance: BigDecimal,
@Json(name = "credit_limit") val creditLimit: BigDecimal,
@Json(name = "pending_charges") val pendingCharges: BigDecimal,
@Json(name = "posted_charges") val postedCharges: BigDecimal,
@Json(name = "balance_due") val balanceDue: BigDecimal,
)
}

View file

@ -0,0 +1,33 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class OrderResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "id") val id: String,
@Json(name = "customer_id") val customerId: String?,
@Json(name = "type") val type: String?,
@Json(name = "status") val status: String,
@Json(name = "step") val step: String?,
@Json(name = "data") val data: Data,
@Json(name = "step_change_code") val stepChangeCode: Int?,
@Json(name = "created_at") val createdAt: String?,
@Json(name = "updated_at") val updatedAt: String?,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "type") val type: String?,
@Json(name = "specification_name") val specificationName: String?,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
@Json(name = "emboss_name") val embossName: String?,
@Json(name = "product_instance_id") val productInstanceId: String?,
@Json(name = "payment_account_id") val paymentAccountId: String?,
)
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class TangemPayTxHistoryResponse(
@Json(name = "error") val error: String?,
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "transactions") val transactions: List<Transaction>,
)
@JsonClass(generateAdapter = true)
data class Transaction(
@Json(name = "id") val id: String, // UUID (used as cursor for pagination)
@Json(name = "type") val type: String, // "SPEND", "COLLATERAL", "PAYMENT", "FEE"
@Json(name = "spend") val spend: Spend? = null,
@Json(name = "collateral") val collateral: Collateral? = null,
@Json(name = "payment") val payment: Payment? = null,
@Json(name = "fee") val fee: Fee? = null,
)
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class Spend(
@Json(name = "amount") val amount: BigDecimal,
@Json(name = "currency") val currency: String,
@Json(name = "local_amount") val localAmount: BigDecimal? = null,
@Json(name = "local_currency") val localCurrency: String? = null,
@Json(name = "authorized_amount") val authorizedAmount: BigDecimal? = null,
@Json(name = "authorization_method") val authorizationMethod: String? = null,
@Json(name = "memo") val memo: String? = null,
@Json(name = "receipt") val receipt: Boolean,
@Json(name = "merchant_name") val merchantName: String,
@Json(name = "merchant_category") val merchantCategory: String,
@Json(name = "merchant_category_code") val merchantCategoryCode: String,
@Json(name = "merchant_id") val merchantId: String? = null,
@Json(name = "enriched_merchant_icon") val enrichedMerchantIcon: String? = null,
@Json(name = "enriched_merchant_name") val enrichedMerchantName: String? = null,
@Json(name = "enriched_merchant_category") val enrichedMerchantCategory: String? = null,
@Json(name = "card_id") val cardId: String? = null,
@Json(name = "card_type") val cardType: String? = null,
@Json(name = "status") val status: String,
@Json(name = "declined_reason") val declinedReason: String? = null,
@Json(name = "authorized_at") val authorizedAt: DateTime,
@Json(name = "posted_at") val postedAt: DateTime?,
)
@JsonClass(generateAdapter = true)
data class Collateral(
@Json(name = "amount") val amount: BigDecimal,
@Json(name = "currency") val currency: String,
@Json(name = "memo") val memo: String? = null,
@Json(name = "chain_id") val chainId: Long,
@Json(name = "wallet_address") val walletAddress: String,
@Json(name = "transaction_hash") val transactionHash: String,
@Json(name = "posted_at") val postedAt: DateTime?,
)
@JsonClass(generateAdapter = true)
data class Payment(
@Json(name = "amount") val amount: BigDecimal,
@Json(name = "currency") val currency: String,
@Json(name = "memo") val memo: String? = null,
@Json(name = "chain_id") val chainId: Long? = null,
@Json(name = "wallet_address") val walletAddress: String? = null,
@Json(name = "transaction_hash") val transactionHash: String? = null,
@Json(name = "status") val status: String,
@Json(name = "posted_at") val postedAt: DateTime,
)
@JsonClass(generateAdapter = true)
data class Fee(
@Json(name = "amount") val amount: BigDecimal,
@Json(name = "currency") val currency: String,
@Json(name = "description") val description: String? = null,
@Json(name = "posted_at") val postedAt: DateTime,
)
}

View file

@ -19,6 +19,7 @@ interface StakeKitApi {
@Query("preferredValidatorsOnly") preferredValidatorsOnly: Boolean? = null,
@Query("ledgerWalletAPICompatible") ledgerWalletAPICompatible: Boolean? = null,
@Query("type") type: YieldType? = null,
@Query("yieldId") yieldId: String? = null,
@Query("revenueOption") revenueOption: RevenueOption? = null,
@Query("page") page: Int? = null,
@Query("network") network: String? = null,

View file

@ -36,6 +36,7 @@ data class ActionRequestBody(
)
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class ActionRequestBodyArgs(
@Json(name = "amount")
val amount: String,

View file

@ -5,6 +5,7 @@ import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class ConstructTransactionRequestBody(
@Json(name = "gasArgs")
val gasArgs: GasArgs? = null,

View file

@ -5,6 +5,7 @@ import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class AddressArgumentDTO(
@Json(name = "required")
val required: Boolean,

View file

@ -1,3 +1,5 @@
@file:Suppress("BooleanPropertyNaming")
package com.tangem.datasource.api.stakekit.models.response.model
import com.squareup.moshi.Json

View file

@ -1,3 +1,5 @@
@file:Suppress("BooleanPropertyNaming")
package com.tangem.datasource.api.stakekit.models.response.model
import com.squareup.moshi.Json
@ -198,6 +200,7 @@ data class YieldDTO(
enum class RewardTypeDTO {
@Json(name = "apy")
APY, // compound rate
@Json(name = "apr")
APR, // simple rate,

View file

@ -156,7 +156,7 @@ interface TangemTechApi {
@Path("walletId") walletId: String,
@Header("If-Match") eTag: String,
@Body body: SaveWalletAccountsResponse,
): ApiResponse<Unit>
): ApiResponse<GetWalletAccountsResponse>
@GET("/v1/wallets/{walletId}/accounts/archived")
suspend fun getWalletArchivedAccounts(

View file

@ -0,0 +1,43 @@
package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
import com.tangem.datasource.api.tangemTech.models.YieldModuleStatusResponse
import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface YieldSupplyApi {
@GET("api/v1/yield/markets")
suspend fun getYieldMarkets(@Query("chainId") chainId: String? = null): ApiResponse<YieldMarketsResponse>
@GET("api/v1/yield/token/{chainId}/{tokenAddress}")
suspend fun getYieldTokenStatus(
@Path("chainId") chainId: Int,
@Path("tokenAddress") tokenAddress: String,
): ApiResponse<YieldSupplyMarketTokenDto>
@GET("api/v1/yield/token/{chainId}/{tokenAddress}/chart")
suspend fun getYieldTokenChart(
@Path("chainId") chainId: Int,
@Path("tokenAddress") tokenAddress: String,
@Query("window") window: String? = null,
@Query("bucketSizeDays") bucketSizeDays: Int? = null,
): ApiResponse<YieldTokenChartResponse>
@POST("api/v1/module/activate")
suspend fun activateYieldModule(
@Body body: YieldSupplyChangeTokenStatusBody,
): ApiResponse<YieldModuleStatusResponse>
@POST("api/v1/module/deactivate")
suspend fun deactivateYieldModule(
@Body body: YieldSupplyChangeTokenStatusBody,
): ApiResponse<YieldModuleStatusResponse>
}

View file

@ -1,3 +1,5 @@
@file:Suppress("BooleanPropertyNaming")
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
@ -12,12 +14,13 @@ data class CoinsResponse(
) {
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class Coin(
@Json(name = "id") val id: String,
@Json(name = "name") val name: String,
@Json(name = "symbol") val symbol: String,
@Json(name = "active") val active: Boolean,
@Json(name = "networks") val networks: List<Network> = listOf(),
@Json(name = "networks") val networks: List<Network> = emptyList(),
) {
@JsonClass(generateAdapter = true)

View file

@ -4,6 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class CreateUserNetworkAccountResponse(
@Json(name = "status") val status: Boolean,
@Json(name = "data") val data: AccountCreated,

View file

@ -24,7 +24,5 @@ data class SeedPhraseNotificationDTO(val status: Status) {
@Json(name = "accepted")
ACCEPTED,
;
}
}

View file

@ -5,6 +5,7 @@ import com.squareup.moshi.JsonClass
import com.tangem.common.extensions.calculateHashCode
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class UserTokensResponse(
@Json(name = "version") val version: Int = 0,
@Json(name = "group") val group: GroupType,

View file

@ -4,6 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class WalletBody(
@Json(name = "notifyStatus") val notifyStatus: Boolean? = null,
@Json(name = "name") val name: String? = null,

View file

@ -4,6 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class WalletResponse(
@Json(name = "notifyStatus") val notifyStatus: Boolean,
@Json(name = "id") val id: String,

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class YieldMarketsResponse(
@Json(name = "tokens") val marketDtos: List<YieldSupplyMarketTokenDto>,
@Json(name = "lastUpdatedAt") val lastUpdated: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class YieldModuleStatusResponse(
@Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "chainId") val chainId: Int,
@Json(name = "isActive") val isActive: Boolean,
@Json(name = "activatedAt") val activatedAt: String?,
@Json(name = "deactivatedAt") val deactivatedAt: String?,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class YieldSupplyChangeTokenStatusBody(
@Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "chainId") val chainId: Int,
@Json(name = "userAddress") val userAddress: String,
)

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class YieldSupplyMarketTokenDto(
@Json(name = "tokenAddress") val tokenAddress: String? = null,
@Json(name = "tokenSymbol") val tokenSymbol: String? = null,
@Json(name = "tokenName") val tokenName: String? = null,
@Json(name = "apy") val apy: BigDecimal? = null,
@Json(name = "isActive") val isActive: Boolean? = null,
@Json(name = "chainId") val chainId: Int? = null,
@Json(name = "maxFeeNative") val maxFeeNative: String? = null,
@Json(name = "maxFeeUSD") val maxFeeUSD: String? = null,
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class YieldTokenChartResponse(
@Json(name = "underlying") val underlying: String,
@Json(name = "market") val market: String,
@Json(name = "bucketSizeDays") val bucketSizeDays: Int,
@Json(name = "period") val period: String,
@Json(name = "data") val data: List<DataPoint>,
@Json(name = "avr") val averageApy: BigDecimal,
) {
@JsonClass(generateAdapter = true)
data class DataPoint(
@Json(name = "bucketIndex") val bucketIndex: Int,
@Json(name = "avgApy") val avgApy: BigDecimal,
)
}

View file

@ -2,8 +2,37 @@ package com.tangem.datasource.api.tangemTech.models.account
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.utils.SerializeNulls
@JsonClass(generateAdapter = true)
data class SaveWalletAccountsResponse(
@Json(name = "accounts") val accounts: List<WalletAccountDTO>,
)
@Json(name = "accounts") val accounts: List<AccountDTO>,
) {
@SerializeNulls
@JsonClass(generateAdapter = true)
data class AccountDTO(
@Json(name = "id") val id: String,
@Json(name = "name") val name: String?,
@Json(name = "derivation") val derivationIndex: Int,
@Json(name = "icon") val icon: String,
@Json(name = "iconColor") val iconColor: String,
)
companion object {
operator fun invoke(accounts: List<WalletAccountDTO>): SaveWalletAccountsResponse {
return SaveWalletAccountsResponse(
accounts = accounts.map { accountDto ->
AccountDTO(
id = accountDto.id,
name = accountDto.name,
derivationIndex = accountDto.derivationIndex,
icon = accountDto.icon,
iconColor = accountDto.iconColor,
)
},
)
}
}
}

View file

@ -39,8 +39,8 @@ class AssetLoader @Inject constructor(
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
onFailure = { throwable ->
Timber.e(throwable, "Failed to load config [$fileName] from assets")
null
},
)
@ -59,8 +59,8 @@ class AssetLoader @Inject constructor(
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
onFailure = { throwable ->
Timber.e(throwable, "Failed to load config [$fileName] from assets")
emptyList()
},
)
@ -79,8 +79,8 @@ class AssetLoader @Inject constructor(
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
onFailure = { throwable ->
Timber.e(throwable, "Failed to load config [$fileName] from assets")
emptyMap()
},
)

View file

@ -42,10 +42,26 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideTangemTechConfig(
environmentConfigStorage: EnvironmentConfigStorage,
appVersionProvider: AppVersionProvider,
authProvider: AuthProvider,
appInfoProvider: AppInfoProvider,
): ApiConfig = TangemTech(
environmentConfigStorage = environmentConfigStorage,
appVersionProvider = appVersionProvider,
authProvider = authProvider,
appInfoProvider = appInfoProvider,
)
@Provides
@IntoSet
fun provideYieldSupplyConfig(
environmentConfigStorage: EnvironmentConfigStorage,
appVersionProvider: AppVersionProvider,
authProvider: AuthProvider,
appInfoProvider: AppInfoProvider,
): ApiConfig = YieldSupply(
environmentConfigStorage = environmentConfigStorage,
appVersionProvider = appVersionProvider,
authProvider = authProvider,
appInfoProvider = appInfoProvider,

View file

@ -9,9 +9,8 @@ import com.tangem.common.json.MoshiJsonConverter
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.utils.SerializeNullsFactory
import com.tangem.domain.models.scan.serialization.*
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -28,6 +27,7 @@ class MoshiModule {
@NetworkMoshi
fun provideNetworkMoshi(): Moshi {
return Moshi.Builder()
.add(SerializeNullsFactory)
.add(
PolymorphicJsonAdapterFactory.of(ProviderModel::class.java, "type")
.withSubtype(ProviderModel.Public::class.java, "public")
@ -38,8 +38,8 @@ class MoshiModule {
.add(BigIntegerAdapter())
.add(LocalDateAdapter())
.add(DateTimeAdapter())
.add(VisaActivationRemoteState.jsonAdapter)
.add(VisaCardActivationStatus.jsonAdapter)
// .add(VisaActivationRemoteState.jsonAdapter)
// .add(VisaCardActivationStatus.jsonAdapter)
.add(
NamePolymorphicAdapterFactory.of(NetworkStatusDM::class.java)
.withSubtype(NetworkStatusDM.Verified::class.java, "amounts")
@ -84,8 +84,8 @@ class MoshiModule {
val typedAdapters = MoshiJsonConverter.getTangemSdkTypedAdapters()
return Moshi.Builder().apply {
add(VisaActivationRemoteState.jsonAdapter)
add(VisaCardActivationStatus.jsonAdapter)
// add(VisaActivationRemoteState.jsonAdapter)
// add(VisaCardActivationStatus.jsonAdapter)
adapters.forEach { this.add(it) }
typedAdapters.forEach { add(it.key, it.value) }
addLast(KotlinJsonAdapterFactory())

View file

@ -15,6 +15,7 @@ import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.di.utils.RetrofitApiBuilder
import com.tangem.datasource.di.utils.RetrofitApiBuilder.Timeouts
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -88,6 +89,15 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideYieldSupplyApi(retrofitApiBuilder: RetrofitApiBuilder): YieldSupplyApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.YieldSupply,
applyTimeoutAnnotations = true,
)
}
@Provides
@Singleton
fun provideTangemTechMarketsApi(retrofitApiBuilder: RetrofitApiBuilder): TangemTechMarketsApi {

View file

@ -3,6 +3,8 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.txhistory.DefaultTxHistoryItemsStore
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.visa.DefaultTangemPayTxHistoryItemsStore
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -20,4 +22,12 @@ internal object TxHistoryItemsStoreModule {
dataStore = RuntimeDataStore(),
)
}
@Provides
@Singleton
fun provideTangemPayTxHistoryItemsStore(): TangemPayTxHistoryItemsStore {
return DefaultTangemPayTxHistoryItemsStore(
dataStore = RuntimeDataStore(),
)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import com.tangem.datasource.local.yieldsupply.DefaultYieldMarketsStore
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object YieldSupplyModule {
@Provides
@Singleton
fun provideYieldMarketsStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): YieldMarketsStore {
return DefaultYieldMarketsStore(
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = listTypes<YieldSupplyMarketTokenDto>(),
defaultValue = emptyList(),
),
produceFile = { context.dataStoreFile(fileName = "yield_markets_cache") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
)
}
}

View file

@ -192,6 +192,7 @@ internal class RetrofitApiBuilder @Inject constructor(
}
}
@Suppress("UseEmptyCounterpart")
private companion object {
val excludedApiForLogging: Set<ApiConfig.ID> = setOf(

View file

@ -72,15 +72,16 @@ internal class DefaultExpressServiceLoader @Inject constructor(
return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } }
}
@Suppress("SuspendFunWithFlowReturnType")
private suspend fun getInitializationStatusInternal(userWalletId: UserWalletId): InitializationStatusFlow {
val initializationStatus = initializationStatuses.value.get(key = userWalletId)
val initializationStatus = initializationStatuses.value[userWalletId]
if (initializationStatus != null) return initializationStatus
val cached = expressAssetsStore.getSyncOrNull(userWalletId)
val default: InitializationStatusFlow = MutableStateFlow(value = cached?.lceContent() ?: lceLoading())
initializationStatuses.update {
it.toMutableMap().apply {
initializationStatuses.update { statuses ->
statuses.toMutableMap().apply {
put(key = userWalletId, value = default)
}
}

View file

@ -16,10 +16,10 @@ internal class DefaultAvailableAppCurrenciesStore(
override suspend fun store(response: CurrenciesResponse) {
val currencies = response.currencies
.map {
it.copy(
iconSmallUrl = response.imageHost?.plus(IMAGE_SMALL)?.format(it.id),
iconMediumUrl = response.imageHost?.plus(IMAGE_MEDIUM)?.format(it.id),
.map { currency ->
currency.copy(
iconSmallUrl = response.imageHost?.plus(IMAGE_SMALL)?.format(currency.id),
iconMediumUrl = response.imageHost?.plus(IMAGE_MEDIUM)?.format(currency.id),
)
}
.associateBy(CurrenciesResponse.Currency::code)

View file

@ -17,4 +17,9 @@ data class EnvironmentConfig(
val devExpress: ExpressModel? = null,
val stakeKitApiKey: String? = null,
val blockAidApiKey: String? = null,
val tangemApiKey: String? = null,
val tangemApiKeyDev: String? = null,
val tangemApiKeyStage: String? = null,
val yieldModuleApiKey: String? = null,
val yieldModuleApiKeyDev: String? = null,
)

View file

@ -26,6 +26,11 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
devExpress = value.devExpress,
stakeKitApiKey = value.stakeKitApiKey,
blockAidApiKey = value.blockaidApiKey,
tangemApiKey = value.tangemApiKey,
tangemApiKeyDev = value.tangemApiKeyDev,
tangemApiKeyStage = value.tangemApiKeyStage,
yieldModuleApiKey = value.yieldModuleApiKey,
yieldModuleApiKeyDev = value.yieldModuleApiKeyDev,
)
}
}

View file

@ -40,8 +40,12 @@ class EnvironmentConfigModel(
@Json(name = "moralisApiKey") val moralisApiKey: String?,
@Json(name = "nftScanApiKey") val nftScanApiKey: String?,
@Json(name = "blockaidApiKey") val blockaidApiKey: String?,
@Json(name = "tangemApiKey") val tangemApiKey: String?,
@Json(name = "tangemApiKeyDev") val tangemApiKeyDev: String?,
@Json(name = "tangemApiKeyStage") val tangemApiKeyStage: String?,
@Json(name = "etherscanApiKey") val etherScanApiKey: String?,
@Json(name = "yieldModuleApiKey") val yieldModuleApiKey: String?,
@Json(name = "yieldModuleApiKeyDev") val yieldModuleApiKeyDev: String?,
@Json(name = "blinkApiKey") val blinkApiKey: String?,
@Json(name = "tatumApiKey") val tatumApiKey: String?,
)

View file

@ -9,7 +9,7 @@ import java.math.BigDecimal
/**
* Network status for storage in the local cache. Supports two types - the [Verified] and [NoAccount].
*
* @see [com.tangem.domain.tokens.model.NetworkStatus]
* @see [com.tangem.domain.models.network.NetworkStatus]
*/
@JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER)
sealed interface NetworkStatusDM {
@ -41,8 +41,8 @@ sealed interface NetworkStatusDM {
@Json(name = "derivation_path") override val derivationPath: DerivationPath,
@Json(name = "selected_address") override val selectedAddress: String,
@Json(name = "available_addresses") override val availableAddresses: Set<Address>,
@Json(name = "amounts") val amounts: Map<String, BigDecimal>,
@Json(name = "yield_supply_statuses") val yieldSupplyStatuses: Map<String, YieldSupplyStatus?> = emptyMap(),
@Json(name = "amounts") val amounts: List<CurrencyAmount>,
@Json(name = "yield_supply_statuses") val yieldSupplyStatuses: List<YieldSupplyStatus>,
) : NetworkStatusDM
/**
@ -107,10 +107,45 @@ sealed interface NetworkStatusDM {
}
}
@JsonClass(generateAdapter = true)
data class CurrencyAmount(
@Json(name = "id") val id: CurrencyId,
@Json(name = "amount") val amount: BigDecimal,
)
@JsonClass(generateAdapter = true)
data class YieldSupplyStatus(
@Json(name = "id") val id: CurrencyId,
@Json(name = "is_active") val isActive: Boolean,
@Json(name = "is_initialized") val isInitialized: Boolean,
@Json(name = "is_allowed_to_spend") val isAllowedToSpend: Boolean,
@Json(name = "effective_protocol_balance") val effectiveProtocolBalance: BigDecimal? = null,
)
@JsonClass(generateAdapter = true)
data class CurrencyId(
@Json(name = "value") val value: String,
) {
companion object Companion {
const val CONTRACT_ADDRESS_DELIMITER = '\u2693' // ⚓
fun createCoinId(coinId: String): CurrencyId {
return CurrencyId(value = coinId)
}
fun createTokenId(rawTokenId: String?, contractAddress: String): CurrencyId {
return CurrencyId(
value = buildString {
if (rawTokenId != null) {
append(rawTokenId)
}
append(CONTRACT_ADDRESS_DELIMITER)
append(contractAddress)
},
)
}
}
}
}

View file

@ -26,22 +26,22 @@ object NFTSdkAssetConverter : TwoWayConverter<Pair<Network, SdkNFTAsset>, NFTAss
amount = asset.amount,
decimals = asset.decimals,
salePrice = salePrice,
rarity = asset.rarity?.let {
rarity = asset.rarity?.let { rarity ->
NFTAsset.Rarity(
rank = it.rank,
label = it.label,
rank = rarity.rank,
label = rarity.label,
)
},
media = asset.media?.let {
media = asset.media?.let { media ->
NFTAsset.Media(
animationUrl = it.animationUrl,
imageUrl = it.imageUrl,
animationUrl = media.animationUrl,
imageUrl = media.imageUrl,
)
},
traits = asset.traits.map {
traits = asset.traits.map { trait ->
NFTAsset.Trait(
name = it.name,
value = it.value,
name = trait.name,
value = trait.value,
)
},
source = StatusSource.CACHE,
@ -65,22 +65,22 @@ object NFTSdkAssetConverter : TwoWayConverter<Pair<Network, SdkNFTAsset>, NFTAss
amount = value.amount,
decimals = value.decimals,
salePrice = salePrice,
rarity = value.rarity?.let {
rarity = value.rarity?.let { rarity ->
SdkNFTAsset.Rarity(
rank = it.rank,
label = it.label,
rank = rarity.rank,
label = rarity.label,
)
},
media = value.media?.let {
media = value.media?.let { media ->
SdkNFTAsset.Media(
animationUrl = it.animationUrl,
imageUrl = it.imageUrl,
animationUrl = media.animationUrl,
imageUrl = media.imageUrl,
)
},
traits = value.traits.map {
traits = value.traits.map { trait ->
SdkNFTAsset.Trait(
name = it.name,
value = it.value,
name = trait.name,
value = trait.value,
)
},
)

View file

@ -32,12 +32,12 @@ class NFTSdkCollectionConverter(
.filter {
it.id !is NFTAsset.Identifier.Unknown
}
.let {
if (it.isEmpty()) {
.let { items ->
if (items.isEmpty()) {
NFTCollection.Assets.Empty
} else {
NFTCollection.Assets.Value(
items = it,
items = items,
source = StatusSource.CACHE,
)
}

View file

@ -50,8 +50,8 @@ internal object PreferencesDataStore {
private fun createCorruptionHandler(): ReplaceFileCorruptionHandler<Preferences> {
return ReplaceFileCorruptionHandler(
produceNewData = {
Timber.w(it)
produceNewData = { corruptionException ->
Timber.w(corruptionException)
emptyPreferences()
},
)

View file

@ -129,6 +129,8 @@ object PreferencesKeys {
val WALLETS_NFT_ENABLED_STATES_KEY by lazy { stringPreferencesKey(name = "walletsNftEnabledStates") }
val YIELD_SUPPLY_WARNINGS_STATES_KEY by lazy { stringPreferencesKey(name = "yieldSupplyWarningsStates") }
// region Notifications
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }
@ -162,6 +164,9 @@ object PreferencesKeys {
// region Permission
fun getShouldShowPermission(permission: String) = booleanPreferencesKey("shouldShowPushPermission_$permission")
fun getShouldShowAskNotificationPermissionViaBs() =
booleanPreferencesKey("ShouldShowAskNotificationPermissionViaBs")
fun getShouldShowInitialPermissionScreen(permission: String) =
booleanPreferencesKey("shouldShowInitialPushPermissionScreen_$permission")
// endregion

View file

@ -14,9 +14,9 @@ inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String
val adapter = moshi.adapter(T::class.java)
emitAll(
data.map { preferences ->
preferences[key]?.let {
preferences[key]?.let { value ->
try {
adapter.fromJson(it)
adapter.fromJson(value)
} catch (e: JsonDataException) {
null
}
@ -37,9 +37,9 @@ inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String
return flow {
val adapter = moshi.adapter(T::class.java)
emitAll(
data.map {
data.map { prefs ->
try {
it[key]?.let(adapter::fromJson) ?: default
prefs[key]?.let(adapter::fromJson) ?: default
} catch (e: JsonDataException) {
default
}
@ -59,9 +59,9 @@ suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrNull(key: Pref
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
data.firstOrNull()
?.get(key)
?.let {
?.let { value ->
try {
adapter.fromJson(it)
adapter.fromJson(value)
} catch (e: JsonDataException) {
null
}
@ -76,9 +76,9 @@ suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrDefault(
val adapter = moshi.adapter(T::class.java)
data.firstOrNull()
?.get(key)
?.let {
?.let { value ->
try {
adapter.fromJson(it)
adapter.fromJson(value)
} catch (e: JsonDataException) {
default
}
@ -159,7 +159,7 @@ inline fun <reified V> AppPreferencesStore.getObjectMap(key: Preferences.Key<Str
val adapter = moshi.adapter<Map<String, V>>(type)
emitAll(
data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() },
data.map { it[key]?.let(adapter::fromJson).orEmpty() },
)
}
}
@ -180,7 +180,7 @@ inline fun <reified T> AppPreferencesStore.getObjectSet(key: Preferences.Key<Str
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
emitAll(
data.map {
it[key]?.let(adapter::fromJson) ?: emptySet()
it[key]?.let(adapter::fromJson).orEmpty()
},
)
}

View file

@ -12,10 +12,10 @@ internal class DefaultSwapBestRateAnimationStore(
* If true, reset flag to false
*/
override suspend fun getSyncOrNull(): Boolean {
val value = dataStore.getSyncOrNull() ?: true
if (value) {
val shouldShowBestRateAnimation = dataStore.getSyncOrNull() != false
if (shouldShowBestRateAnimation) {
dataStore.store(false)
}
return value
return shouldShowBestRateAnimation
}
}

View file

@ -42,8 +42,8 @@ internal class DefaultExpressAssetsStore(
runtimeStore.store(userWalletId.stringValue, item)
}
launch {
persistenceStore.updateData {
it.toMutableMap().apply {
persistenceStore.updateData { assetsByWalletId ->
assetsByWalletId.toMutableMap().apply {
put(userWalletId.stringValue, item)
}
}

View file

@ -24,8 +24,19 @@ internal class DefaultStakingYieldsStore(
}
override suspend fun store(items: List<YieldDTO>) {
dataStore.updateData { _ ->
items
dataStore.updateData { data ->
val updatedItems = data.toMutableList()
items.forEach { newItem ->
val existingItemIndex = data.indexOfFirst { it.id == newItem.id }
if (existingItemIndex != -1) {
// Update existing item
updatedItems[existingItemIndex] = newItem
} else {
// Add new item
updatedItems.add(newItem)
}
}
updatedItems
}
}

View file

@ -8,7 +8,7 @@ internal class DefaultTokenReceiveWarningActionStore(
) : TokenReceiveWarningActionStore {
override suspend fun getSync(): Set<String> {
return persistenceStore.data.firstOrNull() ?: emptySet()
return persistenceStore.data.firstOrNull().orEmpty()
}
override suspend fun store(symbol: String) {

View file

@ -12,26 +12,26 @@ internal object PendingActionConverter : Converter<BalanceDTO.PendingAction, Pen
passthrough = value.passthrough,
args = with(value.args) {
PendingAction.PendingActionArgs(
amount = this?.amount?.let {
amount = this?.amount?.let { amount ->
PendingAction.PendingActionArgs.Amount(
required = it.required,
minimum = it.minimum,
maximum = it.maximum,
required = amount.required,
minimum = amount.minimum,
maximum = amount.maximum,
)
},
duration = this?.duration?.let {
duration = this?.duration?.let { duration ->
PendingAction.PendingActionArgs.Duration(
required = it.required,
minimum = it.minimum,
maximum = it.maximum,
required = duration.required,
minimum = duration.minimum,
maximum = duration.maximum,
)
},
validatorAddress = this?.validatorAddress?.required,
validatorAddresses = this?.validatorAddresses?.required,
tronResource = this?.tronResource?.let {
tronResource = this?.tronResource?.let { tronResource ->
PendingAction.PendingActionArgs.TronResource(
required = it.required,
options = it.options,
required = tronResource.required,
options = tronResource.options,
)
},
signatureVerification = this?.signatureVerification?.required,

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.datasource.local.datastore.core.StringKeyDataStoreDecorator
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
internal class DefaultTangemPayTxHistoryItemsStore(
dataStore: StringKeyDataStore<Map<String, List<TangemPayTxHistoryItem>>>,
) : TangemPayTxHistoryItemsStore,
StringKeyDataStoreDecorator<String, Map<String, List<TangemPayTxHistoryItem>>>(dataStore) {
override fun provideStringKey(key: String): String = key
override suspend fun getSyncOrNull(key: String, cursor: String): List<TangemPayTxHistoryItem>? {
val storedValue = getSyncOrNull(key)
return storedValue?.get(cursor)
}
override suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>) {
val oldValue = getSyncOrNull(key).orEmpty()
val newValue = oldValue.toMutableMap().apply { put(cursor, value) }
store(key, newValue)
}
}

View file

@ -8,9 +8,11 @@ interface TangemPayStorage {
suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens?
suspend fun storeCustomerWalletAddress(customerWalletAddress: String)
suspend fun storeOrderId(customerWalletAddress: String, orderId: String)
suspend fun getCustomerWalletAddress(): String?
suspend fun getOrderId(customerWalletAddress: String): String?
suspend fun clear(customerWalletAddress: String)
suspend fun clearOrderId(customerWalletAddress: String)
suspend fun clearAll(customerWalletAddress: String)
}

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
interface TangemPayTxHistoryItemsStore {
suspend fun getSyncOrNull(key: String, cursor: String): List<TangemPayTxHistoryItem>?
suspend fun remove(key: String)
suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>)
}

View file

@ -28,14 +28,14 @@ internal class DefaultWalletManagersStore(
): WalletManager? {
val walletManagers = getSyncOrNull(userWalletId)
return walletManagers?.singleOrNull {
it.wallet.blockchain == blockchain &&
it.wallet.publicKey.derivationPath?.rawPath == derivationPath
return walletManagers?.singleOrNull { walletManager ->
walletManager.wallet.blockchain == blockchain &&
walletManager.wallet.publicKey.derivationPath?.rawPath == derivationPath
}
}
override suspend fun getAllSync(userWalletId: UserWalletId): List<WalletManager> {
return getSyncOrNull(userWalletId) ?: emptyList()
return getSyncOrNull(userWalletId).orEmpty()
}
override suspend fun store(userWalletId: UserWalletId, walletManager: WalletManager) {

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.local.yieldsupply
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
internal class DefaultYieldMarketsStore(
private val persistenceStore: DataStore<List<YieldSupplyMarketTokenDto>>,
) : YieldMarketsStore {
override fun get(): Flow<List<YieldSupplyMarketTokenDto>> = persistenceStore.data
override suspend fun getSyncOrNull(): List<YieldSupplyMarketTokenDto>? {
return persistenceStore.data.firstOrNull()
}
override suspend fun store(items: List<YieldSupplyMarketTokenDto>) {
persistenceStore.updateData { _ -> items }
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.local.yieldsupply
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import kotlinx.coroutines.flow.Flow
interface YieldMarketsStore {
fun get(): Flow<List<YieldSupplyMarketTokenDto>>
suspend fun getSyncOrNull(): List<YieldSupplyMarketTokenDto>?
suspend fun store(items: List<YieldSupplyMarketTokenDto>)
}

View file

@ -20,9 +20,9 @@ internal fun OkHttpClient.Builder.addHeaders(
Interceptor { chain ->
val request = chain.request().newBuilder().apply {
runBlocking {
requestHeaders.forEach {
val value = it.value.invoke()
if (value.isNotBlank()) addHeader(name = it.key, value = value)
requestHeaders.forEach { header ->
val value = header.value.invoke()
if (value.isNotBlank()) addHeader(name = header.key, value = value)
}
}
}.build()

View file

@ -101,7 +101,7 @@ class NetworkLogsSaveInterceptor(
private fun logResponseMessage(response: Response, startNs: Long) {
val responseHeaders = response.headers
val responseBody = response.body!!
val responseBody = requireNotNull(response.body)
val contentLength = responseBody.contentLength()
val message = if (!response.promisesBody()) {

View file

@ -46,8 +46,8 @@ sealed class RequestHeader(vararg pairs: Pair<String, ProviderSuspend<String>>)
fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]
val charCondition = c == '\t' || c in '\u0020'..'\u007e'
if (!charCondition) {
val isChar = c == '\t' || c in '\u0020'..'\u007e'
if (!isChar) {
return ""
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.datasource.utils
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class SerializeNulls

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.utils
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.lang.reflect.Type
/**
* Factory to serialize nulls in Moshi if the class is annotated with [SerializeNulls].
*
[REDACTED_AUTHOR]
*/
internal object SerializeNullsFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: MutableSet<out Annotation>, moshi: Moshi): JsonAdapter<*>? {
val rawType = Types.getRawType(type)
if (!rawType.isAnnotationPresent(SerializeNulls::class.java)) {
return null
}
val nextAdapter: JsonAdapter<Any> = moshi.nextAdapter(this, type, annotations)
return nextAdapter.serializeNulls()
}
}

View file

@ -37,8 +37,17 @@ class ApiConfigTest {
appInfoProvider = mockk(),
)
}
ApiConfig.ID.YieldSupply -> {
YieldSupply(
environmentConfigStorage = mockk(),
appVersionProvider = mockk(),
authProvider = mockk(),
appInfoProvider = mockk(),
)
}
ApiConfig.ID.TangemTech -> {
TangemTech(
environmentConfigStorage = mockk(),
appVersionProvider = mockk(),
authProvider = mockk(),
appInfoProvider = mockk(),

View file

@ -16,6 +16,11 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
express = ExpressModel(apiKey = EXPRESS_API_KEY, signVerifierPublicKey = "vocibus"),
devExpress = ExpressModel(apiKey = EXPRESS_DEV_API_KEY, signVerifierPublicKey = "pellentesque"),
blockAidApiKey = BLOCK_AID_API_KEY,
tangemApiKey = TANGEM_API_KEY,
tangemApiKeyDev = TANGEM_API_KEY_DEV,
tangemApiKeyStage = TANGEM_API_KEY_STAGE,
yieldModuleApiKey = YIELD_MODULE_KEY,
yieldModuleApiKeyDev = YIELD_MODULE_KEY_DEV,
)
override suspend fun initialize() = environmentConfig
@ -26,5 +31,10 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
const val EXPRESS_API_KEY = "express_api_key"
const val EXPRESS_DEV_API_KEY = "express_dev_api_key"
const val BLOCK_AID_API_KEY = "block_aid_api_key"
const val TANGEM_API_KEY = "tangem_api_key"
const val TANGEM_API_KEY_DEV = "tangem_api_key_dev"
const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage"
const val YIELD_MODULE_KEY = "yield_module_api_key"
const val YIELD_MODULE_KEY_DEV = "yield_module_api_key_dev"
}
}

View file

@ -84,8 +84,17 @@ internal class ProdApiConfigsManagerTest {
appInfoProvider = appInfoProvider,
)
}
ApiConfig.ID.YieldSupply -> {
YieldSupply(
environmentConfigStorage = environmentConfigStorage,
appVersionProvider = appVersionProvider,
authProvider = appAuthProvider,
appInfoProvider = appInfoProvider,
)
}
ApiConfig.ID.TangemTech -> {
TangemTech(
environmentConfigStorage = environmentConfigStorage,
appVersionProvider = appVersionProvider,
authProvider = appAuthProvider,
appInfoProvider = appInfoProvider,
@ -101,6 +110,7 @@ internal class ProdApiConfigsManagerTest {
private fun provideTestModels() = ApiConfig.ID.entries.map {
when (it) {
ApiConfig.ID.Express -> createExpressModel()
ApiConfig.ID.YieldSupply -> createYieldSupplyModel()
ApiConfig.ID.TangemTech -> createTangemTechModel()
ApiConfig.ID.StakeKit -> createStakeKitModel()
ApiConfig.ID.TangemPay -> createTangemPayModel()
@ -165,6 +175,30 @@ internal class ProdApiConfigsManagerTest {
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/",
headers = mapOf(
"api-key" to ProviderSuspend { MockEnvironmentConfigStorage.TANGEM_API_KEY },
"card_id" to ProviderSuspend { APP_CARD_ID },
"card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY },
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "android" },
"system_version" to ProviderSuspend { "Android 16" },
"language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() },
"timezone" to ProviderSuspend {
TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty()
},
"device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() },
),
),
)
}
private fun createYieldSupplyModel(): TestModel {
return TestModel(
id = ApiConfig.ID.YieldSupply,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://yield.tangem.org/",
headers = mapOf(
"api-key" to ProviderSuspend { MockEnvironmentConfigStorage.YIELD_MODULE_KEY },
"card_id" to ProviderSuspend { APP_CARD_ID },
"card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY },
"version" to ProviderSuspend { VERSION_NAME },
@ -242,6 +276,6 @@ internal class ProdApiConfigsManagerTest {
const val EXPRESS_SESSION_ID = "express_session_id"
const val STAKE_KIT_API_KEY = "stake_kit_api_key"
const val APP_CARD_ID = "app_card_id"
const val APP_CARD_PUBLIC_KEY = "app_public_key"
const val APP_CARD_PUBLIC_KEY = "Bearer app_public_key"
}
}

View file

@ -64,9 +64,15 @@ class NetworkStatusDMSerializationTest {
NetworkStatusDM.Address("0x123456", NetworkStatusDM.Address.Type.Primary),
NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary),
),
amounts = mapOf("ETH" to BigDecimal("1.2345")),
yieldSupplyStatuses = mapOf(
"ETH" to NetworkStatusDM.YieldSupplyStatus(
amounts = listOf(
NetworkStatusDM.CurrencyAmount(
id = NetworkStatusDM.CurrencyId.createCoinId("ethereum"),
amount = BigDecimal("1.2345"),
),
),
yieldSupplyStatuses = listOf(
NetworkStatusDM.YieldSupplyStatus(
id = NetworkStatusDM.CurrencyId.createCoinId("ethereum"),
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
@ -91,9 +97,15 @@ class NetworkStatusDMSerializationTest {
NetworkStatusDM.Address("0x123456", NetworkStatusDM.Address.Type.Primary),
NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary),
),
amounts = mapOf("ETH" to BigDecimal("1.2345")),
yieldSupplyStatuses = mapOf(
"ETH" to NetworkStatusDM.YieldSupplyStatus(
amounts = listOf(
NetworkStatusDM.CurrencyAmount(
id = NetworkStatusDM.CurrencyId.createCoinId("ethereum"),
amount = BigDecimal("1.2345"),
),
),
yieldSupplyStatuses = listOf(
NetworkStatusDM.YieldSupplyStatus(
id = NetworkStatusDM.CurrencyId.createCoinId("ethereum"),
isActive = false,
isInitialized = false,
isAllowedToSpend = false,

View file

@ -0,0 +1,51 @@
package com.tangem.datasource.utils
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
// --- DTO ---
@SerializeNulls
@JsonClass(generateAdapter = true)
data class UserWithNulls(val id: String?, val name: String?)
@JsonClass(generateAdapter = true)
data class UserWithoutNulls(val id: String?, val name: String?)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SerializeNullsFactoryTest {
private val moshi = Moshi.Builder()
.add(SerializeNullsFactory)
.build()
@Test
fun `should serialize nulls for annotated class`() {
val adapter = moshi.adapter(UserWithNulls::class.java)
val json = adapter.toJson(UserWithNulls(id = null, name = "John"))
assertThat(json).isEqualTo("""{"id":null,"name":"John"}""")
}
@Test
fun `should skip nulls for non-annotated class`() {
val adapter = moshi.adapter(UserWithoutNulls::class.java)
val json = adapter.toJson(UserWithoutNulls(id = null, name = "John"))
assertThat(json).isEqualTo("""{"name":"John"}""")
}
@Test
fun `should deserialize annotated class correctly`() {
val adapter = moshi.adapter(UserWithNulls::class.java)
val json = """{"id":null,"name":"Jane"}"""
val result = adapter.fromJson(json)
assertThat(result).isEqualTo(UserWithNulls(id = null, name = "Jane"))
}
}

View file

@ -37,6 +37,7 @@ inline fun <reified M : Model> AppComponentContext.getOrCreateModel(): M = getOr
* @param params The parameters to store in the [ParamsContainer],
*/
@Suppress("NullableToStringCall")
inline fun <reified M : Model, reified P : Any> AppComponentContext.getOrCreateModel(
params: P?,
messageSender: UiMessageSender? = null,

View file

@ -0,0 +1,92 @@
package com.tangem.pagination.fetcher
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.exception.EndOfPaginationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
/**
* First page: cursor = null
* Next pages: cursor = cursorFromItem(lastItemOfPreviousPage)
*/
class CursorBatchFetcher<TRequestParams : Any, TItem : Any>(
private val prefetchDistance: Int,
private val batchSize: Int,
private val subFetcher: SubFetcher<TRequestParams, TItem>,
private val cursorFromItem: (TItem) -> String,
) : BatchFetcher<TRequestParams, List<TItem>> {
data class Request<TRequestParams>(
val limit: Int,
val cursor: String?,
val params: TRequestParams,
)
fun interface SubFetcher<TRequestParams : Any, TItem : Any> {
suspend fun fetch(
request: Request<TRequestParams>,
lastResult: BatchFetchResult<List<TItem>>?,
isFirstBatchFetching: Boolean,
): BatchFetchResult<List<TItem>>
}
private val lastRequest = MutableStateFlow<Request<TRequestParams>?>(null)
override suspend fun fetchFirst(requestParams: TRequestParams): BatchFetchResult<List<TItem>> {
val request = Request(
cursor = null,
limit = prefetchDistance,
params = requestParams,
)
val result = runCatching {
subFetcher.fetch(request = request, lastResult = null, isFirstBatchFetching = true)
}.getOrElse {
currentCoroutineContext().ensureActive()
return BatchFetchResult.Error(it)
}
lastRequest.value = request
return result
}
override suspend fun fetchNext(
overrideRequestParams: TRequestParams?,
lastResult: BatchFetchResult<List<TItem>>,
): BatchFetchResult<List<TItem>> {
val lastRequest = requireNotNull(lastRequest.value) { "fetchFirst() must be called before fetchNext()" }
if (lastResult is BatchFetchResult.Success && lastResult.last && overrideRequestParams == null) {
return BatchFetchResult.Error(EndOfPaginationException())
}
val nextReq: Request<TRequestParams> =
if (lastResult is BatchFetchResult.Success<List<TItem>>) {
val items = lastResult.data
if (items.isEmpty()) {
return BatchFetchResult.Error(EndOfPaginationException())
}
val nextCursor = cursorFromItem(items.last())
Request(
cursor = nextCursor,
limit = batchSize,
params = overrideRequestParams ?: lastRequest.params,
)
} else {
lastRequest.copy(limit = batchSize, params = overrideRequestParams ?: lastRequest.params)
}
val result = runCatching {
subFetcher.fetch(request = nextReq, lastResult = lastResult, isFirstBatchFetching = false)
}.getOrElse {
currentCoroutineContext().ensureActive()
return BatchFetchResult.Error(it)
}
this.lastRequest.value = nextReq
return result
}
}

View file

@ -25,11 +25,16 @@ fun Resources.getStringSafe(@StringRes id: Int): String {
*/
fun Resources.getStringSafe(@StringRes id: Int, vararg formatArgs: Any): String {
return runCatching { getString(id, *formatArgs) }
.recoverCatching {
.recoverCatching { throwable ->
// If something goes wrong, returns the resource without arguments
val string = getString(id)
reportIssue(it, resources = this, id, *formatArgs)
reportIssue(
throwable = throwable,
resources = this,
id = id,
formatArgs = formatArgs,
)
string
}
@ -60,8 +65,13 @@ fun Resources.getPluralStringSafe(@PluralsRes id: Int, count: Int, vararg format
}
private fun Result<String>.getOrResourceName(resources: Resources, id: Int, vararg formatArgs: Any): String {
return getOrElse {
reportIssue(it, resources, id, formatArgs)
return getOrElse { throwable ->
reportIssue(
throwable = throwable,
resources = resources,
id = id,
formatArgs = formatArgs,
)
// If something still goes wrong, returns the resource name
resources.getResourceEntryName(id)

View file

@ -7,6 +7,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
/**
@ -30,4 +32,29 @@ fun BottomFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemThe
),
),
)
}
/**
* A composable that draws a fade effect. Used on screens with a list of repeating
* elements and floating button at the bottom of the screen.
*/
@Composable
fun Fade(
modifier: Modifier = Modifier,
backgroundColor: Color = TangemTheme.colors.background.secondary,
height: Dp = 32.dp,
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
backgroundColor,
),
),
),
)
}

View file

@ -68,9 +68,9 @@ fun CardWithIcon(
internal fun IconWithTitleAndDescription(
title: String,
description: String?,
iconBackground: Color = TangemTheme.colors.background.secondary,
icon: @Composable () -> Unit,
additionalContent: @Composable () -> Unit = {},
iconBackground: Color = TangemTheme.colors.background.secondary,
) {
Row(
modifier = Modifier

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