Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-21 18:34:38 +03:00
commit 438126dc82
665 changed files with 16174 additions and 6245 deletions

View file

@ -25,7 +25,6 @@ sealed class ApiConfig {
TangemTech,
StakeKit,
TangemPay,
Attestation,
BlockAid,
}
@ -35,7 +34,6 @@ sealed class ApiConfig {
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is TangemPay -> ID.TangemPay
is Attestation -> ID.Attestation
is BlockAid -> ID.BlockAid
}
}

View file

@ -13,9 +13,15 @@ enum class ApiEnvironment {
@Json(name = "DEV")
DEV,
@Json(name = "DEV_2")
DEV_2,
@Json(name = "STAGE")
STAGE,
@Json(name = "MOCK")
MOCK,
@Json(name = "PROD")
PROD,
}

View file

@ -1,26 +0,0 @@
package com.tangem.datasource.api.common.config
/**
* Attestation [ApiConfig] that used in CardSDK
*
[REDACTED_AUTHOR]
*/
internal class Attestation : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createProdEnvironment(),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem-tech.com/",
)
}

View file

@ -14,6 +14,7 @@ import com.tangem.utils.version.AppVersionProvider
* @property environmentConfigStorage environment config storage
* @property expressAuthProvider express auth provider
* @property appVersionProvider app version provider
* @property appInfoProvider app info provider
*/
internal class Express(
private val environmentConfigStorage: EnvironmentConfigStorage,
@ -26,22 +27,51 @@ internal class Express(
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createDev2Environment(),
createStageEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.STAGE
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
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(isProd = false),
)
private fun createDev2Environment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV_2,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(isProd = false),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(isProd = false),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(isProd = false),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://express.tangem.com/v1/",
@ -63,21 +93,4 @@ internal class Express(
?.apiKey
?: error("No express config provided")
}
private companion object {
fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> ApiEnvironment.STAGE
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
@ -7,11 +8,37 @@ internal class TangemPay(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.DEV
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createProdEnvironment(),
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
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 = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
@ -20,12 +47,6 @@ internal class TangemPay(
headers = createHeaders(),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.info.AppInfoProvider
@ -12,18 +13,27 @@ internal class TangemTech(
private val appInfoProvider: AppInfoProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createDevEnvironment(),
createStageEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/v1/",
headers = createHeaders(),
)
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,
@ -31,6 +41,24 @@ internal class TangemTech(
headers = createHeaders(),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/",
headers = createHeaders(),
)
private fun createHeaders() = buildMap {
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
putAll(from = RequestHeader.AuthenticationHeader(authProvider).values)

View file

@ -20,44 +20,42 @@ import kotlinx.coroutines.flow.*
* @property dispatchers coroutine dispatcher provider
*/
internal class DevApiConfigsManager(
apiConfigs: ApiConfigs,
private val apiConfigs: ApiConfigs,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MutableApiConfigsManager {
) : MutableApiConfigsManager() {
override val configs: Flow<Map<ApiConfig, ApiEnvironment>> get() = _apiConfigs
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
private val _apiConfigs = MutableStateFlow(value = apiConfigs.associateWith { it.defaultEnvironment })
override val isInitialized: StateFlow<Boolean> get() = _isInitialized.asStateFlow()
private val _isInitialized = MutableStateFlow(value = false)
override val isInitialized: StateFlow<Boolean>
field = MutableStateFlow(value = false)
override fun initialize() {
_isInitialized.value = false
isInitialized.value = false
// We can't use appPreferencesStore.getObjectMap as base flow,
// because we should keep possibility to work with configs synchronous.
// See [getBaseUrl]
appPreferencesStore.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
.distinctUntilChanged()
.onEach { savedEnvironments ->
_apiConfigs.update { apiConfigs ->
apiConfigs.mapValues {
val (config, currentEnvironment) = it
val apiConfigs = configs.value
savedEnvironments[config.id.name] ?: currentEnvironment
}
configs.value = apiConfigs.mapValues {
val (config, currentEnvironment) = it
savedEnvironments[config.id.name] ?: currentEnvironment
}
if (!_isInitialized.value) {
_isInitialized.value = true
if (!isInitialized.value) {
isInitialized.value = true
}
notifyListeners(apiConfigs = apiConfigs, savedEnvironments = savedEnvironments)
}
.launchIn(CoroutineScope(SupervisorJob() + dispatchers.default))
}
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {
val apiConfigs = _apiConfigs.value
val apiConfigs = configs.value
val config = apiConfigs.map { it }.firstOrNull { it.key.id == id }?.key
?: error("Api config with id [$id] not found")
@ -78,4 +76,56 @@ internal class DevApiConfigsManager(
mutablePreferences.setObjectMap(PreferencesKeys.apiConfigsEnvironmentKey, updatedMap)
}
}
override suspend fun changeEnvironment(environment: ApiEnvironment) {
val supportedConfigs = apiConfigs
.filter { config ->
config.environmentConfigs.any { it.environment == environment }
}
.map { it.id.name }
appPreferencesStore.editData { mutablePreferences ->
val updatedMap = mutablePreferences.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
.mapValues { (configName, currentEnvironment) ->
val isEnvSupported = supportedConfigs.contains(configName)
if (isEnvSupported) environment else currentEnvironment
}
mutablePreferences.setObjectMap(key = PreferencesKeys.apiConfigsEnvironmentKey, value = updatedMap)
}
}
private fun notifyListeners(
apiConfigs: Map<ApiConfig, ApiEnvironment>,
savedEnvironments: Map<String, ApiEnvironment>,
) {
if (registerListeners.isNotEmpty()) {
val changedConfigs = apiConfigs.mapNotNull { (config, prevEnvironment) ->
val newEnvironment = savedEnvironments[config.id.name] ?: config.defaultEnvironment
if (prevEnvironment == newEnvironment) return@mapNotNull null
val environmentConfig = config.environmentConfigs
.firstOrNull { it.environment == newEnvironment }
?: return@mapNotNull null
config.id to environmentConfig
}
if (changedConfigs.isNotEmpty()) {
registerListeners.forEach { listener ->
val changedConfig = changedConfigs.firstOrNull { it.first == listener.id }?.second
if (changedConfig != null) {
listener.onChange(changedConfig)
}
}
}
}
}
private fun getInitialConfigs(): Map<ApiConfig, ApiEnvironment> {
return apiConfigs.associateWith(ApiConfig::defaultEnvironment)
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.datasource.api.common.config.managers
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
/**
* Implementation of [ApiConfigsManager] in MOCK environment
*
* @property apiConfigs api configs
*
[REDACTED_AUTHOR]
*/
internal class MockApiConfigsManager(
private val apiConfigs: ApiConfigs,
dispatchers: CoroutineDispatcherProvider,
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
override val isInitialized: StateFlow<Boolean> = MutableStateFlow(value = true)
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
override fun initialize() = Unit
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {
val apiConfigs = configs.value
val (config, currentEnvironment) = apiConfigs.entries.firstOrNull { it.key.id == id }
?: error("Api config with id [$id] not found")
return config.environmentConfigs.firstOrNull { it.environment == currentEnvironment }
?: error("Api config with id [$id] doesn't contain environment [$currentEnvironment]")
}
override suspend fun changeEnvironment(id: String, environment: ApiEnvironment) {
configs.update { apiConfigs ->
val apiConfig = apiConfigs.keys.firstOrNull { it.id.name == id }
?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI")
apiConfigs + (apiConfig to environment)
}
}
override suspend fun changeEnvironment(environment: ApiEnvironment) {
configs.update { apiConfigs ->
apiConfigs.mapValues { (config, currentEnvironment) ->
val isEnvSupported = config.environmentConfigs.any { it.environment == environment }
if (isEnvSupported) environment else currentEnvironment
}
}
}
override fun addListener(listener: ApiConfigEnvChangeListener) {
super.addListener(listener)
configs
.map { it.entries.firstOrNull { it.key.id == listener.id } }
.filterNotNull()
.onEach { (apiConfig, currentEnvironment) ->
listener.onChange(
environmentConfig = apiConfig.environmentConfigs.first { it.environment == currentEnvironment },
)
}
.launchIn(coroutineScope)
}
private fun getInitialConfigs(): Map<ApiConfig, ApiEnvironment> {
return apiConfigs.associateWith { it.defaultEnvironment }
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.common.config.managers
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import kotlinx.coroutines.flow.Flow
/**
@ -9,11 +10,41 @@ import kotlinx.coroutines.flow.Flow
*
[REDACTED_AUTHOR]
*/
interface MutableApiConfigsManager : ApiConfigsManager {
abstract class MutableApiConfigsManager : ApiConfigsManager {
/** Api configs with current [ApiEnvironment] */
val configs: Flow<Map<ApiConfig, ApiEnvironment>>
abstract val configs: Flow<Map<ApiConfig, ApiEnvironment>>
/**
* A set of listeners registered to observe changes in API environment configurations.
* These listeners are notified whenever an environment change occurs.
*/
protected val registerListeners: Set<ApiConfigEnvChangeListener>
field = mutableSetOf<ApiConfigEnvChangeListener>()
/** Change api environment [environment] by [id] */
suspend fun changeEnvironment(id: String, environment: ApiEnvironment)
abstract suspend fun changeEnvironment(id: String, environment: ApiEnvironment)
/** Change api environment [environment] for all configs */
abstract suspend fun changeEnvironment(environment: ApiEnvironment)
/** Adds a [listener] to observe changes in API environment configurations */
open fun addListener(listener: ApiConfigEnvChangeListener) {
registerListeners += listener
}
/**
* Listener for observing changes in API environment configurations
*
* @property id the identifier of the API configuration this listener is associated with
*/
abstract class ApiConfigEnvChangeListener(val id: ApiConfig.ID) {
/**
* Called when the environment configuration changes
*
* @param environmentConfig the updated [ApiEnvironmentConfig] for the associated API configuration
*/
abstract fun onChange(environmentConfig: ApiEnvironmentConfig)
}
}

View file

@ -9,7 +9,7 @@ import retrofit2.http.Query
interface TangemTechMarketsApi {
@Suppress("LongParameterList")
@GET("coins/list")
@GET("v1/coins/list")
suspend fun getCoinsList(
@Query("currency") currency: String,
@Query("interval") interval: String,
@ -20,24 +20,24 @@ interface TangemTechMarketsApi {
@Query("timestamp") timestamp: Long?,
): ApiResponse<TokenMarketListResponse>
@GET("coins/{coin_id}")
@GET("v1/coins/{coin_id}")
suspend fun getCoinMarketData(
@Path("coin_id") coinId: String,
@Query("currency") currency: String,
@Query("language") language: String,
): ApiResponse<TokenMarketInfoResponse>
@GET("coins/{coin_id}/history")
@GET("v1/coins/{coin_id}/history")
suspend fun getCoinChart(
@Path("coin_id") coinId: String,
@Query("currency") currency: String,
@Query("interval") interval: String,
): ApiResponse<TokenMarketChartResponse>
@GET("coins/{coin_id}/exchanges")
@GET("v1/coins/{coin_id}/exchanges")
suspend fun getCoinExchanges(@Path("coin_id") coinId: String): ApiResponse<TokenMarketExchangesResponse>
@GET("coins/history_preview")
@GET("v1/coins/history_preview")
suspend fun getCoinsListCharts(
@Query("coin_ids") coinIds: String,
@Query("currency") currency: String,

View file

@ -17,7 +17,7 @@ import java.util.concurrent.TimeUnit
@Suppress("TooManyFunctions")
interface TangemTechApi {
@GET("coins")
@GET("v1/coins")
suspend fun getCoins(
@Header("Cache-Control") cacheControl: String = "max-age=600",
@Query("contractAddress") contractAddress: String? = null,
@ -30,150 +30,150 @@ interface TangemTechApi {
@Query("limit") limit: Int? = null,
): ApiResponse<CoinsResponse>
@GET("rates")
@GET("v1/rates")
suspend fun getRates(@Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String): RatesResponse
@GET("currencies")
@GET("v1/currencies")
suspend fun getCurrencyList(
@Header("Cache-Control") cacheControl: String = "max-age=600",
): ApiResponse<CurrenciesResponse>
@GET("geo")
@GET("v1/geo")
suspend fun getUserCountryCode(): GeoResponse
@GET("user-tokens/{user-id}")
@GET("v1/user-tokens/{user-id}")
suspend fun getUserTokens(@Path(value = "user-id") userId: String): ApiResponse<UserTokensResponse>
@PUT("user-tokens/{user-id}")
@PUT("v1/user-tokens/{user-id}")
suspend fun saveUserTokens(
@Path(value = "user-id") userId: String,
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
@POST("user-tokens")
@POST("v1/user-tokens")
suspend fun markUserWallerWasCreated(@Body body: MarkUserWalletWasCreatedBody): ApiResponse<Unit>
/** Returns referral status by [walletId] */
@GET("referral/{walletId}")
@GET("v1/referral/{walletId}")
suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse<ReferralResponse>
/** Make user referral, requires [StartReferralBody] */
@POST("referral")
@POST("v1/referral")
suspend fun startReferral(@Body startReferralBody: StartReferralBody): ApiResponse<ReferralResponse>
@GET("quotes")
@GET("v1/quotes")
suspend fun getQuotes(
@Query("currencyId") currencyId: String,
@Query("coinIds") coinIds: String,
@Query("fields") fields: String,
): ApiResponse<QuotesResponse>
@GET("promotion")
@GET("v1/promotion")
suspend fun getPromotionInfo(
@Query("programName") name: String,
@Header("Cache-Control") cacheControl: String = "max-age=600",
): ApiResponse<PromotionInfoResponse>
@GET("settings/{wallet_id}")
@GET("v1/settings/{wallet_id}")
suspend fun getUserTokensSettings(@Path("wallet_id") walletId: String): ApiResponse<UserTokensSettingsResponse>
@PUT("settings/{wallet_id}")
@PUT("v1/settings/{wallet_id}")
suspend fun saveUserTokensSettings(
@Path("wallet_id") walletId: String,
@Body userTokensSettings: UserTokensSettingsResponse,
): ApiResponse<Unit>
@POST("user-network-account")
@POST("v1/user-network-account")
suspend fun createUserNetworkAccount(
@Body body: CreateUserNetworkAccountBody,
): ApiResponse<CreateUserNetworkAccountResponse>
@POST("account")
@POST("v1/account")
suspend fun createUserTokensAccount(
@Body body: CreateUserTokensAccountBody,
): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}")
@PUT("v1/account/{account_id}")
suspend fun updateUserTokensAccount(
@Path("account_id") accountId: Int,
@Body body: UpdateUserTokensAccountBody,
): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}/archive")
@PUT("v1/account/{account_id}/archive")
suspend fun archiveUserTokensAccount(@Path("account_id") accountId: Int): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}/unarchive")
@PUT("v1/account/{account_id}/unarchive")
suspend fun restoreUserTokensAccount(@Path("account_id") accountId: Int): ApiResponse<UserTokensAccountResponse>
@GET("features")
@GET("v1/features")
suspend fun getFeatures(): ApiResponse<FeaturesResponse>
@ReadTimeout(duration = 5, unit = TimeUnit.SECONDS)
@GET("networks/providers")
@GET("v1/networks/providers")
suspend fun getBlockchainProviders(): Map<String, List<ProviderModel>>
@GET("seedphrase-notification/{wallet_id}")
@GET("v1/seedphrase-notification/{wallet_id}")
suspend fun getSeedPhraseNotificationStatus(
@Path("wallet_id") walletId: String,
): ApiResponse<SeedPhraseNotificationDTO>
@PUT("seedphrase-notification/{wallet_id}")
@PUT("v1/seedphrase-notification/{wallet_id}")
suspend fun updateSeedPhraseNotificationStatus(
@Path("wallet_id") walletId: String,
@Body body: SeedPhraseNotificationDTO,
): ApiResponse<Unit>
@GET("seedphrase-notification/{wallet_id}/confirmed")
@GET("v1/seedphrase-notification/{wallet_id}/confirmed")
suspend fun getSeedPhraseSecondNotificationStatus(
@Path("wallet_id") walletId: String,
): ApiResponse<SeedPhraseNotificationDTO>
@PUT("seedphrase-notification/{wallet_id}/confirmed")
@PUT("v1/seedphrase-notification/{wallet_id}/confirmed")
suspend fun updateSeedPhraseSecondNotificationStatus(
@Path("wallet_id") walletId: String,
@Body body: SeedPhraseNotificationDTO,
): ApiResponse<Unit>
@GET("hot_crypto")
@GET("v1/hot_crypto")
suspend fun getHotCrypto(@Query("currency") currencyId: String): ApiResponse<HotCryptoResponse>
@GET("stories/{story_id}")
@GET("v1/stories/{story_id}")
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
// region push notifications
@GET("notification/push_notifications_eligible_networks")
@GET("v1/notification/push_notifications_eligible_networks")
suspend fun getEligibleNetworksForPushNotifications(): ApiResponse<List<CryptoNetworkResponse>>
@POST("user-wallets/applications/")
@POST("v1/user-wallets/applications/")
suspend fun createApplicationId(
@Body
body: NotificationApplicationCreateBody,
): ApiResponse<NotificationApplicationIdResponse>
@PATCH("user-wallets/applications/{application_id}")
@PATCH("v1/user-wallets/applications/{application_id}")
suspend fun updatePushTokenForApplicationId(
@Path("application_id") applicationId: String,
@Body body: NotificationApplicationCreateBody,
): ApiResponse<Unit>
@PATCH("user-wallets/wallets/{wallet_id}/notify")
@PATCH("v1/user-wallets/wallets/{wallet_id}/notify")
suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>
// endregion
// region wallets
@PATCH("user-wallets/wallets/{wallet_id}")
@PATCH("v1/user-wallets/wallets/{wallet_id}")
suspend fun updateWallet(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>
@POST("user-wallets/wallets/create-and-connect-by-appuid/{application_id}")
@POST("v1/user-wallets/wallets/create-and-connect-by-appuid/{application_id}")
suspend fun associateApplicationIdWithWallets(
@Path("application_id") applicationId: String,
@Body body: List<WalletIdBody>,
): ApiResponse<Unit>
@GET("user-wallets/wallets/{wallet_id}")
@GET("v1/user-wallets/wallets/{wallet_id}")
suspend fun getWalletById(@Path("wallet_id") walletId: String): ApiResponse<WalletResponse>
@GET("user-wallets/wallets/by-app/{app_id}")
@GET("v1/user-wallets/wallets/by-app/{app_id}")
suspend fun getWallets(@Path("app_id") appId: String): ApiResponse<List<WalletResponse>>
// endregion
}

View file

@ -60,8 +60,4 @@ internal object ApiConfigsModule {
fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig {
return BlockAid(environmentConfigStorage)
}
@Provides
@IntoSet
fun provideAttestationConfig(): ApiConfig = Attestation()
}

View file

@ -6,18 +6,20 @@ import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.DevApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MockApiConfigsManager
import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.markets.TangemTechMarketsApi
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.TangemTechApiV2
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.utils.*
@ -45,7 +47,7 @@ internal object NetworkModule {
private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L
private val excludedApiForLogging: Set<ApiConfig.ID> = setOf(
// ApiConfig.ID.StakeKit,
ApiConfig.ID.StakeKit,
)
@Provides
@ -55,10 +57,10 @@ internal object NetworkModule {
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): ApiConfigsManager {
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers)
} else {
ProdApiConfigsManager(apiConfigs)
return when {
BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs, dispatchers)
BuildConfig.TESTER_MENU_ENABLED -> DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers)
else -> ProdApiConfigsManager(apiConfigs)
}
}

View file

@ -8,8 +8,10 @@ import com.squareup.moshi.Moshi
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.*
import com.tangem.datasource.local.token.DefaultStakingActionsStore
import com.tangem.datasource.local.token.DefaultStakingYieldsStore
import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.datasource.utils.mapWithStringKeyTypes
@ -66,17 +68,6 @@ internal object StakingStoreModule {
)
}
@Provides
@Singleton
fun provideStakingBalanceStore(
persistenceStore: DataStore<Map<String, Set<YieldBalanceWrapperDTO>>>,
): StakingBalanceStore {
return DefaultStakingBalanceStore(
persistenceStore = persistenceStore,
runtimeStore = RuntimeSharedStore(),
)
}
@Provides
@Singleton
fun provideStakingActionsStore(): StakingActionsStore {

View file

@ -1,269 +0,0 @@
package com.tangem.datasource.local.token
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.StakingBalanceStore.StakingID
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
internal typealias YieldBalanceWrappersDTO = Map<String, Set<YieldBalanceWrapperDTO>>
internal typealias YieldBalanceListByWalletId = Map<UserWalletId, Set<YieldBalance>>
/**
* Default implementation of [StakingBalanceStore]
*
* @property persistenceStore persistence store
* @property runtimeStore runtime store
*/
internal class DefaultStakingBalanceStore(
private val persistenceStore: DataStore<YieldBalanceWrappersDTO>,
private val runtimeStore: RuntimeSharedStore<YieldBalanceListByWalletId>,
) : StakingBalanceStore {
override fun get(userWalletId: UserWalletId, stakingIds: List<StakingID>): Flow<Set<YieldBalance>> = channelFlow {
val cachedBalances = persistenceStore.data
.map {
val wrappers = it[userWalletId.stringValue].orEmpty()
.filter { wrapper ->
stakingIds.any { id ->
id.address == wrapper.addresses.address && id.integrationId == wrapper.integrationId
}
}
YieldBalanceConverter(isCached = true).convertSet(input = wrappers)
}
.firstOrNull()
.orEmpty()
if (cachedBalances.isNotEmpty()) {
send(cachedBalances)
}
runtimeStore.get()
.map {
it[userWalletId].orEmpty().filter { balance ->
stakingIds.any { id ->
id.address == balance.address && id.integrationId == balance.integrationId
}
}
.toSet()
}
.onEach {
val mergedBalances = mergeYieldBalances(
stakingIds = stakingIds,
cachedBalances = cachedBalances,
runtimeBalances = it,
)
send(mergedBalances)
}
.launchIn(scope = this)
}
override fun get(userWalletId: UserWalletId, stakingID: StakingID): Flow<YieldBalance?> {
return get(userWalletId = userWalletId, stakingIds = listOf(stakingID)).map { balances ->
balances.getBalance(stakingID = stakingID)
}
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>? {
val runtimeBalances = runtimeStore.getSyncOrNull()?.getValue(userWalletId).orEmpty()
val cachedBalances = persistenceStore.data.firstOrNull()?.get(userWalletId.stringValue).orEmpty()
if (runtimeBalances.isEmpty() && cachedBalances.isEmpty()) return null
return cachedBalances.mapTo(hashSetOf()) {
val cached = YieldBalanceConverter(source = StatusSource.ONLY_CACHE).convert(value = it)
val runtime = runtimeBalances.getBalance(address = cached.address, integrationId = cached.integrationId)
if (runtime == null || runtime is YieldBalance.Error) cached else runtime
}
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingIds: List<StakingID>): Set<YieldBalance>? {
val runtime = runtimeStore.getSyncOrNull()?.getValue(userWalletId)
val cached = persistenceStore.data.firstOrNull()?.get(userWalletId.stringValue)
if (runtime.isNullOrEmpty() && cached.isNullOrEmpty()) return null
return mergeYieldBalances(
cachedBalances = YieldBalanceConverter(source = StatusSource.ONLY_CACHE)
.convertSet(input = cached.orEmpty()),
runtimeBalances = runtime.orEmpty(),
stakingIds = stakingIds,
)
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingID: StakingID): YieldBalance? {
val balances = getSyncOrNull(userWalletId = userWalletId, stakingIds = listOf(stakingID)) ?: return null
return balances.getBalance(stakingID = stakingID)
}
override suspend fun store(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>) {
coroutineScope {
launch {
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = items)
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalances) { old, new ->
old.integrationId == new.integrationId && old.address == new.address
}
?: newBalances
}
}
}
launch { storeInPersistenceStore(userWalletId = userWalletId, items = items) }
}
}
override suspend fun refresh(userWalletId: UserWalletId, stakingIds: List<StakingID>) {
updateRuntimeStore(userWalletId = userWalletId) { saved ->
saved.mapTo(hashSetOf()) {
val yieldBalance = it.takeIf { balance ->
stakingIds.any { id -> balance.integrationId == id.integrationId && balance.address == id.address }
}
yieldBalance?.copySealed(source = StatusSource.CACHE) ?: it
}
}
}
override suspend fun store(userWalletId: UserWalletId, stakingID: StakingID, item: YieldBalanceWrapperDTO) {
coroutineScope {
launch {
storeInRuntimeStore(
userWalletId = userWalletId,
integrationId = stakingID.integrationId,
address = stakingID.address,
item = item,
)
storeInPersistenceStore(
userWalletId = userWalletId,
integrationId = stakingID.integrationId,
address = stakingID.address,
item = item,
)
}
}
}
override suspend fun storeSingleYieldBalance(userWalletId: UserWalletId, item: YieldBalance) {
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(item) { it.integrationId == item.integrationId && it.address == item.address }
?: setOf(item)
}
}
}
private suspend fun storeInRuntimeStore(
userWalletId: UserWalletId,
integrationId: String,
address: String,
item: YieldBalanceWrapperDTO,
) {
val newBalance = YieldBalanceConverter(isCached = false).convert(value = item)
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalance) { it.integrationId == integrationId && it.address == address }
?: setOf(newBalance)
}
}
}
private suspend fun updateRuntimeStore(
userWalletId: UserWalletId,
function: (Set<YieldBalance>) -> Set<YieldBalance>,
) {
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = function(this[userWalletId].orEmpty())
}
}
}
private suspend fun storeInPersistenceStore(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>) {
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = current[userWalletId.stringValue]
?.addOrReplace(items = items) { old, new ->
old.integrationId == new.integrationId && old.addresses.address == new.addresses.address
}
?: items
}
}
}
private suspend fun storeInPersistenceStore(
userWalletId: UserWalletId,
integrationId: String,
address: String,
item: YieldBalanceWrapperDTO,
) {
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = current[userWalletId.stringValue]
?.addOrReplace(item) { it.integrationId == integrationId && it.addresses.address == address }
?: setOf(item)
}
}
}
private fun mergeYieldBalances(
cachedBalances: Set<YieldBalance>,
runtimeBalances: Set<YieldBalance>,
stakingIds: List<StakingID>,
): Set<YieldBalance> {
return stakingIds.mapTo(hashSetOf()) { id ->
val runtime = runtimeBalances.getBalance(stakingID = id)
if (runtime == null || runtime is YieldBalance.Error) {
getCachedBalanceIfPossible(cachedBalances = cachedBalances, stakingID = id)
} else {
runtime
}
}
}
private fun getCachedBalanceIfPossible(cachedBalances: Set<YieldBalance>, stakingID: StakingID): YieldBalance {
val cached = cachedBalances.getBalance(stakingID)
?: return YieldBalance.Error(integrationId = stakingID.address, address = stakingID.integrationId)
val updatedCached = when (cached) {
is YieldBalance.Data -> cached.copy(source = StatusSource.ONLY_CACHE)
is YieldBalance.Empty -> cached.copy(source = StatusSource.ONLY_CACHE)
is YieldBalance.Error,
is YieldBalance.Unsupported,
-> null
}
return updatedCached ?: YieldBalance.Error(integrationId = stakingID.address, address = stakingID.integrationId)
}
private fun Set<YieldBalance>.getBalance(stakingID: StakingID): YieldBalance? {
return getBalance(address = stakingID.address, integrationId = stakingID.integrationId)
}
private fun Set<YieldBalance>.getBalance(address: String?, integrationId: String?): YieldBalance? {
return firstOrNull { yieldBalance ->
val isCorrectAddress = address != null && address == yieldBalance.address
val isCorrectIntegration = integrationId != null && yieldBalance.integrationId == integrationId
isCorrectIntegration && isCorrectAddress
}
}
}

View file

@ -1,40 +0,0 @@
package com.tangem.datasource.local.token
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.model.stakekit.YieldBalanceList
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
/** Staking balance store */
interface StakingBalanceStore {
/** Get flow of [YieldBalanceList] by [userWalletId] and [stakingIds] */
fun get(userWalletId: UserWalletId, stakingIds: List<StakingID>): Flow<Set<YieldBalance>>
/** Get flow of [YieldBalance] by [userWalletId] and [stakingID] */
fun get(userWalletId: UserWalletId, stakingID: StakingID): Flow<YieldBalance?>
/** Get all [YieldBalance] synchronously or null by [userWalletId] */
suspend fun getSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>?
/** Get [YieldBalanceList] synchronously or null by [userWalletId] and [stakingIds] */
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingIds: List<StakingID>): Set<YieldBalance>?
/** Get [YieldBalance] synchronously or null by [userWalletId] and [stakingID] */
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingID: StakingID): YieldBalance?
/** Store [items] by [userWalletId] */
suspend fun store(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>)
/** Store [item] by [userWalletId] and [stakingID] */
suspend fun store(userWalletId: UserWalletId, stakingID: StakingID, item: YieldBalanceWrapperDTO)
/** Store [item] by [userWalletId] */
suspend fun storeSingleYieldBalance(userWalletId: UserWalletId, item: YieldBalance)
/** Refresh balances of [stakingIds] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, stakingIds: List<StakingID>)
data class StakingID(val integrationId: String, val address: String)
}

View file

@ -5,6 +5,7 @@ import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
import com.tangem.datasource.api.utils.ConnectTimeout
@ -93,7 +94,7 @@ internal fun OkHttpClient.Builder.applyApiConfig(
id: ApiConfig.ID,
apiConfigsManager: ApiConfigsManager,
): OkHttpClient.Builder {
return if (BuildConfig.TESTER_MENU_ENABLED) {
return if (BuildConfig.TESTER_MENU_ENABLED || BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE) {
addInterceptor(
interceptor = SwitchEnvironmentInterceptor(
id = id,

View file

@ -0,0 +1,53 @@
package com.tangem.datasource.api.common.config
import com.google.common.truth.Truth
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ApiConfigTest {
@Test
fun `all baseUrls ends with slash`() {
// Arrange
val allBaseUrls = createApiConfigs().flatMap { it.environmentConfigs.map { it.baseUrl } }
// Actual
val actual = allBaseUrls.all { it.endsWith("/") }
Timber.e(allBaseUrls.joinToString(separator = "\n"))
// Assert
Truth.assertThat(actual).isTrue()
}
private fun createApiConfigs(): ApiConfigs {
return ApiConfig.ID.entries.mapTo(destination = hashSetOf()) {
when (it) {
ApiConfig.ID.Express -> {
Express(
environmentConfigStorage = mockk(),
expressAuthProvider = mockk(),
appVersionProvider = mockk(),
appInfoProvider = mockk(),
)
}
ApiConfig.ID.TangemTech -> {
TangemTech(
appVersionProvider = mockk(),
authProvider = mockk(),
appInfoProvider = mockk(),
)
}
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk())
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk())
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
}
}
}
}

View file

@ -15,6 +15,7 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
private val environmentConfig = EnvironmentConfig(
express = ExpressModel(apiKey = EXPRESS_API_KEY, signVerifierPublicKey = "vocibus"),
devExpress = ExpressModel(apiKey = EXPRESS_DEV_API_KEY, signVerifierPublicKey = "pellentesque"),
blockAidApiKey = BLOCK_AID_API_KEY,
)
override suspend fun initialize() = environmentConfig
@ -24,5 +25,6 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
companion object {
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"
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.common.config.managers
import android.os.Build
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
@ -10,46 +11,47 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.EXTERNAL_BUIL
import com.tangem.datasource.api.common.config.ApiConfig.Companion.INTERNAL_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.util.Locale
import java.util.TimeZone
private val configManager = MockEnvironmentConfigStorage()
private val appVersionProvider = mockk<AppVersionProvider>()
private val expressAuthProvider = mockk<ExpressAuthProvider>()
private val stakeKitAuthProvider = mockk<StakeKitAuthProvider>()
private val appAuthProvider = mockk<AuthProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
// Don't forget to add new config !!!
private val API_CONFIGS = setOf(
Express(configManager, expressAuthProvider, appVersionProvider, appInfoProvider),
TangemTech(appVersionProvider, appAuthProvider, appInfoProvider),
StakeKit(stakeKitAuthProvider),
TangemPay(appVersionProvider),
)
/**
[REDACTED_AUTHOR]
*/
@RunWith(Parameterized::class)
internal class ProdApiConfigsManagerTest(private val model: Model) {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProdApiConfigsManagerTest {
private val manager = ProdApiConfigsManager(API_CONFIGS)
private val environmentConfigStorage = MockEnvironmentConfigStorage()
private val appVersionProvider = mockk<AppVersionProvider>()
private val expressAuthProvider = mockk<ExpressAuthProvider>()
private val stakeKitAuthProvider = mockk<StakeKitAuthProvider>()
private val appAuthProvider = mockk<AuthProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
@Before
private val manager = ProdApiConfigsManager(apiConfigs = createApiConfigs())
@BeforeEach
fun setup() {
clearMocks(
appVersionProvider,
expressAuthProvider,
stakeKitAuthProvider,
appAuthProvider,
appInfoProvider,
)
every { appVersionProvider.versionName } returns VERSION_NAME
every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID
every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY
@ -58,8 +60,9 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
every { appInfoProvider.osVersion } returns "Android 16"
}
@Test
fun test_getEnvironmentConfig() {
@ParameterizedTest
@ProvideTestModels
fun getEnvironmentConfig(model: TestModel) {
val actual = manager.getEnvironmentConfig(id = model.id)
Truth.assertThat(actual.environment).isEqualTo(model.expected.environment)
@ -69,160 +72,175 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
.isEqualTo(model.expected.headers.mapValues { runBlocking { it.value() } })
}
data class Model(val id: ApiConfig.ID, val expected: ApiEnvironmentConfig)
private fun createApiConfigs(): ApiConfigs {
return ApiConfig.ID.entries.mapTo(destination = hashSetOf()) {
when (it) {
ApiConfig.ID.Express -> {
Express(
environmentConfigStorage = environmentConfigStorage,
expressAuthProvider = expressAuthProvider,
appVersionProvider = appVersionProvider,
appInfoProvider = appInfoProvider,
)
}
ApiConfig.ID.TangemTech -> {
TangemTech(
appVersionProvider = appVersionProvider,
authProvider = appAuthProvider,
appInfoProvider = appInfoProvider,
)
}
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider)
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = appVersionProvider)
ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage)
}
}
}
internal companion object {
private fun provideTestModels() = ApiConfig.ID.entries.map {
when (it) {
ApiConfig.ID.Express -> createExpressModel()
ApiConfig.ID.TangemTech -> createTangemTechModel()
ApiConfig.ID.StakeKit -> createStakeKitModel()
ApiConfig.ID.TangemPay -> createTangemPayModel()
ApiConfig.ID.BlockAid -> createBlockAidSdkModel()
}
}
private fun createExpressModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> ApiEnvironment.STAGE
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return TestModel(
id = ApiConfig.ID.Express,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
-> "[REDACTED_ENV_URL]"
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> "[REDACTED_ENV_URL]"
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> "https://express.tangem.com/v1/"
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
},
headers = mapOf(
"api-key" to ProviderSuspend {
if (environment == ApiEnvironment.PROD) {
MockEnvironmentConfigStorage.EXPRESS_API_KEY
} else {
MockEnvironmentConfigStorage.EXPRESS_DEV_API_KEY
}
},
"session-id" to ProviderSuspend { EXPRESS_SESSION_ID },
"version" to ProviderSuspend { VERSION_NAME },
"system_version" to ProviderSuspend { "Android 16" },
"platform" to ProviderSuspend { "android" },
"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 createTangemTechModel(): TestModel {
return TestModel(
id = ApiConfig.ID.TangemTech,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/",
headers = mapOf(
"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 createStakeKitModel(): TestModel {
return TestModel(
id = ApiConfig.ID.StakeKit,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.stakek.it/v1/",
headers = mapOf(
"X-API-KEY" to ProviderSuspend { STAKE_KIT_API_KEY },
"accept" to ProviderSuspend { "application/json" },
),
),
)
}
private fun createTangemPayModel(): TestModel {
return TestModel(
id = ApiConfig.ID.TangemPay,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
),
),
)
}
private fun createBlockAidSdkModel(): TestModel {
return TestModel(
id = ApiConfig.ID.BlockAid,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.blockaid.io/v0/",
headers = mapOf(
"X-API-KEY" to ProviderSuspend { BLOCK_AID_API_KEY },
"accept" to ProviderSuspend { "application/json" },
"content-type" to ProviderSuspend { "application/json" },
),
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]
val charCondition = c == '\t' || c in '\u0020'..'\u007e'
if (!charCondition) {
return ""
}
}
return this
}
data class TestModel(val id: ApiConfig.ID, val expected: ApiEnvironmentConfig)
private companion object {
const val VERSION_NAME = "debug"
const val EXPRESS_USER_ID = "express_user_id"
const val EXPRESS_SESSION_ID = "express_session_id"
const val EXPRESS_REF_CODE = "express_ref_code"
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"
@JvmStatic
@Parameterized.Parameters
fun data(): Collection<Model> = API_CONFIGS.map {
when (it) {
is Express -> createExpressModel()
is TangemTech -> createTangemTechModel()
is StakeKit -> createStakeKitModel()
is TangemPay -> createVisaModel()
is Attestation -> createAttestationModel()
is BlockAid -> createBlockAidSdkModel()
}
}
private fun createExpressModel(): Model {
val environment = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> ApiEnvironment.STAGE
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return Model(
id = ApiConfig.ID.Express,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
-> "[REDACTED_ENV_URL]"
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> "[REDACTED_ENV_URL]"
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> "https://express.tangem.com/v1/"
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
},
headers = mapOf(
"api-key" to ProviderSuspend {
if (environment == ApiEnvironment.PROD) {
MockEnvironmentConfigStorage.EXPRESS_API_KEY
} else {
MockEnvironmentConfigStorage.EXPRESS_DEV_API_KEY
}
},
"session-id" to ProviderSuspend { EXPRESS_SESSION_ID },
"version" to ProviderSuspend { VERSION_NAME },
"system_version" to ProviderSuspend { "Android 16" },
"platform" to ProviderSuspend { "android" },
"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 createTangemTechModel(): Model {
return Model(
id = ApiConfig.ID.TangemTech,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/v1/",
headers = mapOf(
"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 createStakeKitModel(): Model {
return Model(
id = ApiConfig.ID.StakeKit,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.stakek.it/v1/",
headers = mapOf(
"X-API-KEY" to ProviderSuspend { STAKE_KIT_API_KEY },
"accept" to ProviderSuspend { "application/json" },
),
),
)
}
private fun createVisaModel(): Model {
return Model(
id = ApiConfig.ID.TangemPay,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
),
),
)
}
private fun createAttestationModel(): Model {
return Model(
id = ApiConfig.ID.Attestation,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem-tech.com/",
),
)
}
private fun createBlockAidSdkModel(): Model {
return Model(
id = ApiConfig.ID.BlockAid,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.blockaid.io/v0/",
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]
val charCondition = c == '\t' || c in '\u0020'..'\u007e'
if (!charCondition) {
return ""
}
}
return this
}
}
}