Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-10 10:03:05 +04:00
parent c5a596d7fc
commit f388510170
13 changed files with 198 additions and 107 deletions

View file

@ -16,7 +16,9 @@ import com.tangem.crypto.bip39.Wordlist
import com.tangem.data.card.sdk.CardSdkOwner
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MutableApiConfigsManager
import com.tangem.datasource.utils.AddHeadersInterceptor
import com.tangem.datasource.utils.RequestHeader
import com.tangem.operations.attestation.api.TangemApiServiceSettings
@ -56,28 +58,15 @@ internal class DefaultCardSdkProvider @Inject constructor(
get() = holder?.sdk ?: tryToRegisterWithForegroundActivity()
init {
// TODO: [REDACTED_TASK_KEY] Change API's base URL in CardSDK if API environment is changed
// if (BuildConfig.TESTER_MENU_ENABLED) {
// appPreferencesStore.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
// .map {
// when (it[ApiConfig.ID.TangemTech.name]) {
// ApiEnvironment.DEV,
// ApiEnvironment.STAGE,
// ApiEnvironment.MOCK,
// -> false
// ApiEnvironment.PROD,
// null,
// -> true
// }
// }
// .distinctUntilChanged()
// .onEach { isProd ->
// holder?.let {
// it.sdk.config.isTangemAttestationProdEnv = isProd
// }
// }
// .launchIn(CoroutineScope(SupervisorJob() + dispatchers.main))
// }
val mutableManager = apiConfigsManager as? MutableApiConfigsManager
mutableManager?.addListener(
object : MutableApiConfigsManager.ApiConfigEnvChangeListener(id = ApiConfig.ID.TangemTech) {
override fun onChange(environmentConfig: ApiEnvironmentConfig) {
holder?.sdk?.config?.tangemApiBaseUrl = environmentConfig.baseUrl
}
},
)
TangemApiServiceSettings.addInterceptors(
AddHeadersInterceptor(

View file

@ -55,7 +55,7 @@ internal class TangemTech(
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/v1/",
baseUrl = "https://api.tangem.org/",
headers = createHeaders(),
)

View file

@ -23,7 +23,7 @@ internal class DevApiConfigsManager(
private val apiConfigs: ApiConfigs,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MutableApiConfigsManager {
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
@ -34,22 +34,22 @@ internal class DevApiConfigsManager(
override fun initialize() {
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 ->
configs.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
}
notifyListeners(apiConfigs = apiConfigs, savedEnvironments = savedEnvironments)
}
.launchIn(CoroutineScope(SupervisorJob() + dispatchers.default))
}
@ -96,6 +96,35 @@ internal class DevApiConfigsManager(
}
}
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

@ -4,9 +4,10 @@ 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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
/**
* Implementation of [ApiConfigsManager] in MOCK environment
@ -17,13 +18,16 @@ import kotlinx.coroutines.flow.update
*/
internal class MockApiConfigsManager(
private val apiConfigs: ApiConfigs,
) : MutableApiConfigsManager {
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 {
@ -55,6 +59,20 @@ internal class MockApiConfigsManager(
}
}
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,14 +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 */
suspend fun changeEnvironment(environment: ApiEnvironment)
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

@ -58,7 +58,7 @@ internal object NetworkModule {
dispatchers: CoroutineDispatcherProvider,
): ApiConfigsManager {
return when {
BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs)
BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs, dispatchers)
BuildConfig.TESTER_MENU_ENABLED -> DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers)
else -> ProdApiConfigsManager(apiConfigs)
}

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

@ -162,7 +162,7 @@ internal class ProdApiConfigsManagerTest {
id = ApiConfig.ID.TangemTech,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/v1/",
baseUrl = "https://api.tangem.org/",
headers = mapOf(
"card_id" to ProviderSuspend { APP_CARD_ID },
"card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY },

View file

@ -70,8 +70,4 @@ internal class DefaultCardSdkConfigRepository(
override fun setLinkedTerminal(isLinked: Boolean?) {
sdk.config.linkedTerminal = isLinked
}
override fun setTangemApiProdEnvFlag(flag: Boolean) {
// TODO: [REDACTED_TASK_KEY] Change API's base URL in CardSDK if API environment is changed
}
}

View file

@ -36,7 +36,4 @@ interface CardSdkConfigRepository {
/** Set linked terminal by [isLinked] */
fun setLinkedTerminal(isLinked: Boolean?)
/** Set [flag] that determines whether to use Prod environment for Tangem API */
fun setTangemApiProdEnvFlag(flag: Boolean)
}

View file

@ -6,7 +6,6 @@ import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MutableApiConfigsManager
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.feature.tester.impl.BuildConfig
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.environments.state.EnvironmentTogglesScreenUM
@ -30,7 +29,6 @@ import javax.inject.Inject
@HiltViewModel
internal class EnvironmentsTogglesViewModel @Inject constructor(
apiConfigsManager: ApiConfigsManager,
private val cardSdkConfigRepository: CardSdkConfigRepository,
) : ViewModel() {
/** Current ui state */
@ -98,24 +96,7 @@ internal class EnvironmentsTogglesViewModel @Inject constructor(
viewModelScope.launch {
val environment = ApiEnvironment.valueOf(name)
if (id == ApiConfig.ID.TangemTech.name) {
handleTangemTechConfig(environment = environment)
}
mutableApiConfigsManager.changeEnvironment(id = id, environment = environment)
}
}
/** Special logic for [ApiConfig.ID.TangemTech] */
private fun handleTangemTechConfig(environment: ApiEnvironment) {
cardSdkConfigRepository.setTangemApiProdEnvFlag(
flag = when (environment) {
ApiEnvironment.PROD -> true
ApiEnvironment.DEV,
ApiEnvironment.STAGE,
ApiEnvironment.MOCK,
-> false
},
)
}
}