Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-11 18:55:37 +03:00
parent 330ae73357
commit e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions

1
data/visa/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,52 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.visa"
}
dependencies {
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
/** Project - Domain */
implementation(projects.domain.visa)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Project - Libs */
debugImplementation(projects.libs.visa)
/** Libs - Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
implementation(deps.jodatime)
implementation(deps.timber)
implementation(deps.androidx.paging.runtime)
implementation(deps.moshi.kotlin)
kaptForObfuscatingVariants(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
/** Libs - Tangem */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,272 @@
package com.tangem.data.visa
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import arrow.fx.coroutines.parZip
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.address.AddressType
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.visa.config.VisaLibLoader
import com.tangem.data.visa.utils.*
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.common.visa.TangemVisaAuthProvider
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.visa.VisaUtilities
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.repository.VisaRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.lib.visa.api.VisaApi
import com.tangem.lib.visa.model.VisaTxHistoryResponse
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.jvm.Throws
@Singleton
internal class DefaultVisaRepository @Inject constructor(
private val visaLibLoader: VisaLibLoader,
private val tangemTechApi: TangemTechApi,
private val cacheRegistry: CacheRegistry,
private val userWalletsStore: UserWalletsStore,
private val dispatchers: CoroutineDispatcherProvider,
private val visaAuthProvider: TangemVisaAuthProvider,
private val visaAuthRepository: VisaAuthRepository,
) : VisaRepository {
private val currencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
VisaCurrencyFactory()
}
private val txDetailsFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
VisaTxDetailsFactory()
}
private val fetchedCurrencies = MutableStateFlow(
value = hashMapOf<String, VisaCurrency>(),
)
private val fetchedHistoryItems = MutableStateFlow(
value = emptyMap<String, List<VisaTxHistoryResponse.Transaction>>(),
)
override suspend fun getVisaCurrency(userWalletId: UserWalletId, isRefresh: Boolean): VisaCurrency {
val address = makeAddress(userWalletId)
fetchVisaCurrencyIfExpired(address, isRefresh)
return requireNotNull(fetchedCurrencies.value[address]) {
"Unable to find VISA currency for $address"
}
}
private suspend fun fetchVisaCurrencyIfExpired(address: String, isRefresh: Boolean) {
cacheRegistry.invokeOnExpire(
key = getVisaCurrencyKey(address),
skipCache = isRefresh,
block = { fetchVisaCurrency(address) },
)
}
private suspend fun fetchVisaCurrency(address: String) {
val contractInfoProvider = visaLibLoader.getOrCreateProvider()
parZip(
dispatchers.io,
{ contractInfoProvider.getContractInfo(address) },
{ getFiatRate() },
{ contractInfo, fiatRate ->
fetchedCurrencies.update { value ->
value.apply {
put(address, currencyFactory.create(contractInfo, fiatRate))
}
}
},
)
}
override suspend fun getTxHistory(
userWalletId: UserWalletId,
pageSize: Int,
isRefresh: Boolean,
): Flow<PagingData<VisaTxHistoryItem>> {
val userWallet = findVisaUserWallet(userWalletId)
val cardPubKey = getCardPubKey(userWallet)
val api = visaLibLoader.getOrCreateApi()
val pager = Pager(
config = PagingConfig(
pageSize = pageSize,
initialLoadSize = pageSize,
),
pagingSourceFactory = {
VisaTxHistoryPagingSource(
params = VisaTxHistoryPagingSource.Params(
cardPublicKey = cardPubKey,
pageSize = pageSize,
isRefresh = isRefresh,
userWallet = userWallet,
),
cacheRegistry = cacheRegistry,
fetchedItems = fetchedHistoryItems,
dispatchers = dispatchers,
requestTxHistory = { offset, pageSize -> getTxHistory(api, userWalletId, offset, pageSize) },
)
},
)
return pager.flow
}
override suspend fun getTxDetails(userWalletId: UserWalletId, txId: String): VisaTxDetails {
return withContext(dispatchers.io) {
val userWallet = findVisaUserWallet(userWalletId)
val cardPubKey = getCardPubKey(userWallet)
val transaction = fetchedHistoryItems.value[cardPubKey]?.firstOrNull {
it.transactionId.toString() == txId
}
requireNotNull(transaction) { "Transaction not found: $txId" }
txDetailsFactory.create(
transaction = transaction,
walletBlockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain(),
)
}
}
private suspend fun getTxHistory(
api: VisaApi,
userWalletId: UserWalletId,
offset: Int,
pageSize: Int,
): VisaTxHistoryResponse = withContext(dispatchers.io) {
val userWallet = findVisaUserWallet(userWalletId)
val cardPubKey = getCardPubKey(userWallet)
request(userWalletId = userWalletId) {
api.getTxHistory(
authorizationHeader = visaAuthProvider.getAuthHeader(userWallet.cardId),
cardPublicKey = cardPubKey,
limit = pageSize,
offset = offset,
).getOrThrow()
}
}
private suspend fun makeAddress(userWalletId: UserWalletId): String {
if (VisaConstants.IS_DEMO_MODE_ENABLED) return getDemoAddress()
val userWallet = findVisaUserWallet(userWalletId)
val walletAddresses = makeWalletAddresses(userWallet)
val walletAddress = walletAddresses.firstOrNull { it.type == AddressType.Default }
return requireNotNull(walletAddress?.value) {
"Unable to find wallet address"
}
}
private suspend fun getFiatRate(): BigDecimal? {
val fiatCurrencyId = VisaConstants.fiatCurrency.code.lowercase()
val quotes = tangemTechApi.getQuotes(
currencyId = fiatCurrencyId,
coinIds = VisaConstants.TOKEN_ID,
).getOrThrow()
return quotes.quotes[VisaConstants.TOKEN_ID]?.price
}
private fun makeWalletAddresses(userWallet: UserWallet): Set<Address> {
val walletBlockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain()
return walletBlockchain.makeAddresses(getCardPubKey(userWallet).hexToBytes())
}
private fun getCardPubKey(userWallet: UserWallet): String {
if (VisaConstants.IS_DEMO_MODE_ENABLED) return getDemoPublicKey()
val cardWallet = userWallet.scanResponse.card.wallets.firstOrNull {
it.curve == EllipticCurve.Secp256k1
}
requireNotNull(cardWallet) { "Visa card wallet not found" }
return cardWallet.publicKey.toHexString()
}
private suspend fun findVisaUserWallet(userWalletId: UserWalletId): UserWallet {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"No user wallet found: $userWalletId"
}
if (!userWallet.scanResponse.cardTypesResolver.isVisaWallet()) {
error("VISA wallet required: $userWalletId")
}
return userWallet
}
private fun getVisaCurrencyKey(address: String): String {
return "visa_currency_$address"
}
private suspend fun <T : Any> request(
userWalletId: UserWalletId,
requestBlock: suspend () -> T,
): T {
return runCatching {
requestBlock()
}.getOrElse { responseError ->
if (responseError !is ApiResponseError.HttpException ||
responseError.code != ApiResponseError.HttpException.Code.UNAUTHORIZED
) {
throw responseError
}
val authTokens = getAuthTokens(userWalletId)
val newTokens = runCatching {
visaAuthRepository.refreshAccessTokens(authTokens.refreshToken)
}.getOrElse {
if (it is ApiResponseError.HttpException &&
it.code == ApiResponseError.HttpException.Code.UNAUTHORIZED
) {
userWalletsStore.update(userWalletId) { userWallet ->
userWallet.copy(
scanResponse = userWallet.scanResponse.copy(
visaCardActivationStatus = VisaCardActivationStatus.RefreshTokenExpired
)
)
}
}
throw RefreshTokenExpiredException()
}
userWalletsStore.update(userWalletId) { userWallet ->
userWallet.copy(
scanResponse = userWallet.scanResponse.copy(
visaCardActivationStatus = VisaCardActivationStatus.Activated(
visaAuthTokens = newTokens
)
)
)
}
requestBlock()
}
}
@Throws
private suspend fun getAuthTokens(userWalletId: UserWalletId): VisaAuthTokens {
val userWallet = findVisaUserWallet(userWalletId)
val status = userWallet.scanResponse.visaCardActivationStatus ?: error("Visa card activation status not found")
return (status as? VisaCardActivationStatus.Activated)?.visaAuthTokens ?: error("Visa card is not activated")
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.visa.config
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class VisaConfig(
@Json(name = "testnet")
val testnet: Addresses,
@Json(name = "mainnet")
val mainnet: Addresses,
@Json(name = "txHistoryAPIAdditionalHeaders")
val header: Header,
) {
@JsonClass(generateAdapter = true)
data class Addresses(
@Json(name = "paymentAccountRegistry")
val paymentAccountRegistry: String,
@Json(name = "bridgeProcessor")
val bridgeProcessor: String,
)
@JsonClass(generateAdapter = true)
data class Header(
@Json(name = "x-asn")
val xAsn: String,
)
}

View file

@ -0,0 +1,86 @@
package com.tangem.data.visa.config
import com.squareup.moshi.Moshi
import com.tangem.data.visa.BuildConfig
import com.tangem.data.visa.utils.VisaConstants
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.lib.visa.VisaContractInfoProvider
import com.tangem.lib.visa.api.VisaApi
import com.tangem.lib.visa.api.VisaApiBuilder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
internal class VisaLibLoader @Inject constructor(
private val assetLoader: AssetLoader,
@NetworkMoshi private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val createMutex = Mutex()
private var config: VisaConfig? = null
private var provider: VisaContractInfoProvider? = null
private var api: VisaApi? = null
suspend fun getOrCreateProvider(): VisaContractInfoProvider = provider ?: createProvider()
suspend fun getOrCreateApi(): VisaApi = api ?: createApi()
private suspend fun createProvider(): VisaContractInfoProvider = createMutex.withLock {
val config = getOrLoadConfig()
provider = VisaContractInfoProvider.Builder(
useTestnetRpc = VisaConstants.USE_TEST_ENV,
bridgeProcessorAddress = if (VisaConstants.USE_TEST_ENV) {
config.testnet.bridgeProcessor
} else {
config.mainnet.bridgeProcessor
},
paymentAccountRegistryAddress = if (VisaConstants.USE_TEST_ENV) {
config.testnet.paymentAccountRegistry
} else {
config.mainnet.paymentAccountRegistry
},
isNetworkLoggingEnabled = BuildConfig.LOG_ENABLED,
dispatchers = dispatchers,
).build()
return requireNotNull(provider) {
"Visa provider is not created"
}
}
private suspend fun createApi(): VisaApi = createMutex.withLock {
val config = getOrLoadConfig()
api = VisaApiBuilder(
useDevApi = VisaConstants.USE_TEST_ENV,
isNetworkLoggingEnabled = BuildConfig.LOG_ENABLED,
moshi = moshi,
headers = mapOf(
X_ASN_HEADER_NAME to config.header.xAsn,
),
).build()
return requireNotNull(api) {
"Visa API is not created"
}
}
private suspend fun getOrLoadConfig(): VisaConfig {
config = assetLoader.load<VisaConfig>(VISA_CONFIG_FILE_NAME)
return requireNotNull(config) {
"Visa config is not found"
}
}
companion object {
private const val VISA_CONFIG_FILE_NAME = "tangem-app-config/visa_config"
private const val X_ASN_HEADER_NAME = "x-asn"
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.visa.di
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.visa.DefaultVisaRepository
import com.tangem.data.visa.config.VisaLibLoader
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.visa.repository.VisaRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ImplementedVisaDataModule {
@Binds
@Singleton
@ImplementedVisaRepository
fun provideVisaRepository(impl: DefaultVisaRepository): VisaRepository
}

View file

@ -0,0 +1,7 @@
package com.tangem.data.visa.utils
import java.util.Currency
internal fun findCurrencyByNumericCode(code: Int) =
Currency.getAvailableCurrencies().firstOrNull { it.numericCode == code }
?: Currency.getInstance(VisaConstants.fiatCurrency.code)

View file

@ -0,0 +1,44 @@
package com.tangem.data.visa.utils
import com.tangem.domain.appcurrency.model.AppCurrency
internal object VisaConstants {
const val NETWORK_NAME = "Polygon PoS"
const val TOKEN_ID = "tether"
val fiatCurrency = AppCurrency(
code = "EUR",
name = "Euro",
symbol = "",
)
/*
* Must be `false` in production
* Don't forget to change CardTypesResolver.isVisaWallet
* */
const val IS_DEMO_MODE_ENABLED = false
const val USE_TEST_ENV = true
const val DEMO_TESTNET_ADDRESS = "0x51d034eb1563d0d2e66379ef37756d3c14936c44"
const val DEMO_TESTNET_PUBLIC_KEY = "03FA1122B809079F79C4E0F657FE11337FEC88C3FB3C6341B2CE2E4F5D9241DD86"
const val DEMO_MAINNET_ADDRESS = "0x927e3ef2b3d85bacf9e520379f64f6627d323fcd"
const val DEMO_MAINNET_PUBLIC_KEY = "02AC61CD57B8011BEE8BB489FB744845CC113AD379132C56015EE70528B6A88E92"
}
internal fun getDemoAddress(): String {
return if (VisaConstants.USE_TEST_ENV) {
VisaConstants.DEMO_TESTNET_ADDRESS
} else {
VisaConstants.DEMO_MAINNET_ADDRESS
}
}
internal fun getDemoPublicKey(): String {
return if (VisaConstants.USE_TEST_ENV) {
VisaConstants.DEMO_TESTNET_PUBLIC_KEY
} else VisaConstants.DEMO_MAINNET_PUBLIC_KEY
}

View file

@ -0,0 +1,80 @@
package com.tangem.data.visa.utils
import com.tangem.domain.visa.model.VisaCurrency
import com.tangem.lib.visa.model.VisaContractInfo
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.Instant
import java.math.BigDecimal
import java.math.BigInteger
internal class VisaCurrencyFactory {
fun create(contractInfo: VisaContractInfo, fiatRate: BigDecimal?): VisaCurrency {
val now = Instant.now()
val currentLimit = if (contractInfo.limitsChangeDate > now) {
contractInfo.oldLimits
} else {
contractInfo.newLimits
}
val remainingOtpLimit = getRemainingOtp(currentLimit, now)
return VisaCurrency(
symbol = contractInfo.token.symbol,
networkName = VisaConstants.NETWORK_NAME,
decimals = contractInfo.token.decimals,
fiatRate = fiatRate,
fiatCurrency = VisaConstants.fiatCurrency,
balances = with(contractInfo) {
VisaCurrency.Balances(
total = balances.total,
verified = balances.verified,
available = balances.available.forPayment,
blocked = balances.blocked,
debt = balances.debt,
pendingRefund = balances.pendingRefund,
)
},
limits = VisaCurrency.Limits(
remainingOtp = remainingOtpLimit,
remainingNoOtp = minOf(remainingOtpLimit, getRemainingNoOtp(currentLimit, now)),
singleTransaction = currentLimit.singleTransactionLimit,
expirationDate = getLimitsExpirationDate(currentLimit, now),
),
)
}
private fun getRemainingOtp(currentLimit: VisaContractInfo.Limits, now: Instant): BigDecimal {
if (currentLimit.expirationDate >= now) {
return currentLimit.spendLimit.limit - currentLimit.spendLimit.spent
}
return currentLimit.spendLimit.limit
}
private fun getRemainingNoOtp(currentLimit: VisaContractInfo.Limits, now: Instant): BigDecimal {
if (currentLimit.expirationDate >= now) {
return currentLimit.noOtpLimit.limit - currentLimit.noOtpLimit.spent
}
return currentLimit.noOtpLimit.limit
}
private fun getLimitsExpirationDate(currentLimits: VisaContractInfo.Limits, now: Instant): DateTime {
val expirationDate = if (currentLimits.expirationDate >= now) {
currentLimits.expirationDate.toDateTime()
} else {
val spendPeriodDays = currentLimits.spendPeriodSeconds
.div(BigInteger.valueOf(SECONDS_IN_DAY))
.toInt()
now.toDateTime().plusDays(spendPeriodDays)
}
return expirationDate.withZone(DateTimeZone.getDefault())
}
private companion object {
const val SECONDS_IN_DAY = 86_400L
}
}

View file

@ -0,0 +1,55 @@
package com.tangem.data.visa.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.externallinkprovider.TxExploreState
import com.tangem.domain.visa.model.VisaTxDetails
import com.tangem.lib.visa.model.VisaTxHistoryResponse
internal class VisaTxDetailsFactory {
fun create(transaction: VisaTxHistoryResponse.Transaction, walletBlockchain: Blockchain): VisaTxDetails {
return VisaTxDetails(
id = transaction.transactionId.toString(),
type = transaction.transactionType,
status = transaction.transactionStatus,
blockchainAmount = transaction.blockchainAmount,
blockchainFee = transaction.blockchainFee,
transactionAmount = transaction.transactionAmount,
transactionCurrencyCode = transaction.transactionCurrencyCode,
merchantName = transaction.merchantName,
merchantCity = transaction.merchantCity,
merchantCountryCode = transaction.merchantCountryCode,
merchantCategoryCode = transaction.merchantCategoryCode,
fiatCurrency = findCurrencyByNumericCode(transaction.transactionCurrencyCode),
requests = transaction.requests.map { createRequest(it, walletBlockchain) },
)
}
private fun createRequest(
request: VisaTxHistoryResponse.Transaction.Request,
walletBlockchain: Blockchain,
): VisaTxDetails.Request {
return VisaTxDetails.Request(
billingAmount = request.billingAmount,
billingCurrencyCode = request.billingCurrencyCode,
blockchainAmount = request.blockchainAmount,
blockchainFee = request.blockchainFee,
errorCode = request.errorCode,
requestDate = request.requestDt,
requestStatus = request.requestStatus,
requestType = request.requestType,
transactionAmount = request.transactionAmount,
txCurrencyCode = request.transactionCurrencyCode,
id = request.transactionRequestId.toString(),
txHash = request.txHash,
txStatus = request.txStatus,
fiatCurrency = findCurrencyByNumericCode(request.transactionCurrencyCode),
exploreUrl = request.txHash?.let {
when (val txUrl = walletBlockchain.getExploreTxUrl(it)) {
is TxExploreState.Url -> txUrl.url
is TxExploreState.Unsupported -> ""
}
},
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.visa.utils
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.lib.visa.model.VisaTxHistoryResponse
internal class VisaTxHistoryItemFactory {
fun create(transaction: VisaTxHistoryResponse.Transaction): VisaTxHistoryItem {
return VisaTxHistoryItem(
id = transaction.transactionId.toString(),
date = transaction.transactionDt,
amount = transaction.blockchainAmount,
fiatAmount = transaction.transactionAmount,
merchantName = transaction.merchantName,
status = transaction.transactionStatus,
fiatCurrency = findCurrencyByNumericCode(transaction.transactionCurrencyCode),
)
}
}

View file

@ -0,0 +1,108 @@
package com.tangem.data.visa.utils
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.common.visa.TangemVisaAuthProvider
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.lib.visa.api.VisaApi
import com.tangem.lib.visa.model.VisaTxHistoryResponse
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import timber.log.Timber
internal class VisaTxHistoryPagingSource(
params: Params,
private val cacheRegistry: CacheRegistry,
private val fetchedItems: MutableStateFlow<Map<String, List<VisaTxHistoryResponse.Transaction>>>,
private val dispatchers: CoroutineDispatcherProvider,
val requestTxHistory: suspend (offset: Int, pageSize: Int) -> VisaTxHistoryResponse,
) : PagingSource<Int, VisaTxHistoryItem>() {
private val itemsFactory = VisaTxHistoryItemFactory()
private val cardPublicKey = params.cardPublicKey
private val pageSize = params.pageSize
private val isRefresh = params.isRefresh
private val pagedItems = MutableStateFlow<Map<Int, List<VisaTxHistoryItem>>>(
value = emptyMap(),
)
override fun getRefreshKey(state: PagingState<Int, VisaTxHistoryItem>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(pageSize) ?: anchorPage?.nextKey?.minus(pageSize)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, VisaTxHistoryItem> {
val offsetToLoad = params.key ?: INITIAL_OFFSET
return try {
fetchItemsIfExpired(offsetToLoad, pageSize, isRefresh = isRefresh && params is LoadParams.Refresh)
val items = pagedItems.value[offsetToLoad].orEmpty()
val prevOffset = when {
items.isEmpty() -> null
offsetToLoad > INITIAL_OFFSET -> offsetToLoad - pageSize
else -> null
}
val nextOffset = when {
items.isEmpty() -> INITIAL_OFFSET
items.size % pageSize == 0 -> offsetToLoad + pageSize
else -> null
}
LoadResult.Page(items, prevOffset, nextOffset)
} catch (e: Throwable) {
Timber.e(e, "Unable to load the transaction history for the requested offset: $offsetToLoad")
LoadResult.Error(e)
}
}
private suspend fun fetchItemsIfExpired(offset: Int, pageSize: Int, isRefresh: Boolean) {
cacheRegistry.invokeOnExpire(
key = getCacheKey(offset),
skipCache = isRefresh,
block = { fetchItems(offset, pageSize) },
)
}
private suspend fun fetchItems(offset: Int, pageSize: Int) = withContext(dispatchers.io) {
val response = requestTxHistory(offset, pageSize)
fetchedItems.update {
it.toMutableMap().apply {
this[cardPublicKey] = this[cardPublicKey].orEmpty() + response.transactions
}
}
pagedItems.update {
it.toMutableMap().apply {
this[offset] = response.transactions.map(itemsFactory::create)
}
}
}
private fun getCacheKey(offset: Int): String {
return "visa_tx_history_${cardPublicKey}_$offset"
}
class Params(
val userWallet: UserWallet,
val cardPublicKey: String,
val pageSize: Int,
val isRefresh: Boolean,
)
private companion object {
const val INITIAL_OFFSET = 0
}
}

View file

@ -0,0 +1,220 @@
package com.tangem.data.visa
import com.tangem.data.visa.converter.AccessCodeDataConverter
import com.tangem.data.visa.converter.VisaActivationStatusConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest
import com.tangem.datasource.api.visa.models.request.SetPinCodeRequest
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.withContext
@Suppress("LongParameterList")
internal class DefaultVisaActivationRepository @AssistedInject constructor(
@Assisted private val visaCardId: VisaCardId,
private val visaApi: TangemVisaApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaActivationStatusConverter: VisaActivationStatusConverter,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val accessCodeDataConverter: AccessCodeDataConverter,
private val visaAuthRepository: VisaAuthRepository,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getRemoteActivationStatus(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState =
withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getRemoteActivationStatusLongPoll(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getCardWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
customerWalletAddress = request.customerWalletAddress,
).getOrThrow()
}
VisaDataToSignByCardWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
)
}
override suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getCustomerWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
cardWalletAddress = request.cardWalletAddress,
).getOrThrow()
}
VisaDataToSignByCustomerWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
)
}
override suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet) {
withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.activateByCardWallet(
authHeader = authTokens.getAuthHeader(),
body = ActivationByCardWalletRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = signedData.dataToSign.request.orderId,
data = ActivationByCardWalletRequest.Data(
cardWallet = ActivationByCardWalletRequest.CardWallet(
address = signedData.cardWalletAddress,
cardWalletConfirmation = null, // for second iteration
deployAcceptanceSignature = signedData.signature,
),
otp = ActivationByCardWalletRequest.Otp(
rootOtp = signedData.rootOTP,
counter = signedData.otpCounter,
),
),
),
)
}
}
}
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) {
withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.activateByCustomerWallet(
authHeader = authTokens.getAuthHeader(),
body = ActivationByCustomerWalletRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = signedData.dataToSign.request.orderId,
data = ActivationByCustomerWalletRequest.Data(
customerWallet = ActivationByCustomerWalletRequest.CustomerWallet(
address = signedData.customerWalletAddress,
deployAcceptanceSignature = signedData.signature,
),
),
),
)
}
}
}
override suspend fun sendPinCode(pinCode: VisaEncryptedPinCode) {
withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.setPinCode(
authHeader = authTokens.getAuthHeader(),
body = SetPinCodeRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = pinCode.activationOrderId,
data = SetPinCodeRequest.Data(
sessionKey = pinCode.sessionId,
iv = pinCode.iv,
encryptedPin = pinCode.encryptedPin,
),
),
)
}
}
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): T {
return runCatching {
requestBlock()
}.getOrElse { responseError ->
if (responseError !is ApiResponseError.HttpException ||
responseError.code != ApiResponseError.HttpException.Code.UNAUTHORIZED
) {
throw responseError
}
val authTokens = visaAuthTokenStorage.get(visaCardId.cardId) ?: error("Auth tokens are not stored")
val newTokens = runCatching {
visaAuthRepository.refreshAccessTokens(authTokens.refreshToken)
}.getOrElse { throw RefreshTokenExpiredException() }
visaAuthTokenStorage.store(visaCardId.cardId, newTokens)
requestBlock()
}
}
@AssistedFactory
interface Factory : VisaActivationRepository.Factory {
override fun create(cardId: VisaCardId): DefaultVisaActivationRepository
}
}

View file

@ -0,0 +1,93 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.datasource.api.visa.TangemVisaAuthApi
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
@Suppress("UnusedPrivateMember")
internal class DefaultVisaAuthRepository @Inject constructor(
private val visaAuthApi: TangemVisaAuthApi,
private val dispatchers: CoroutineDispatcherProvider,
) : VisaAuthRepository {
override suspend fun getCardAuthChallenge(cardId: String, cardPublicKey: String): VisaAuthChallenge.Card =
withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByCard(
// cardId = cardId,
// cardPublicKey = cardPublicKey,
// )
//
// VisaAuthChallenge.Card(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
VisaAuthChallenge.Card(
challenge = CryptoUtils.generateRandomBytes(length = 16).toHexString(),
session = VisaAuthSession("session"),
)
}
override suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): VisaAuthChallenge.Wallet =
withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByWalletAddress(
// customerId = cardId,
// customerWalletAddress = walletPublicKey,
// )
//
// VisaAuthChallenge.Wallet(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
VisaAuthChallenge.Wallet(
challenge = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
session = VisaAuthSession("session"),
)
}
override suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens =
withContext(dispatchers.io) {
// val response = when (signedChallenge) {
// is VisaAuthSignedChallenge.ByCardPublicKey -> {
// visaAuthApi.getAccessToken(
// sessionId = signedChallenge.challenge.session.sessionId,
// signature = signedChallenge.signature,
// salt = signedChallenge.salt,
// )
// }
// is VisaAuthSignedChallenge.ByWallet -> {
// visaAuthApi.getAccessToken(
// sessionId = signedChallenge.challenge.session.sessionId,
// signature = signedChallenge.signature,
// salt = null,
// )
// }
// }
//
// VisaAuthTokens(
// accessToken = response.accessToken,
// refreshToken = response.refreshToken,
// )
VisaAuthTokens(
accessToken = "accessToken",
refreshToken = VisaAuthTokens.RefreshToken("refreshToken"),
)
}
override suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens =
withContext(dispatchers.io) {
// TODO
VisaAuthTokens(
accessToken = "accessToken",
refreshToken = VisaAuthTokens.RefreshToken("new refreshToken"),
)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.visa
import androidx.paging.PagingData
import com.tangem.domain.visa.model.VisaCurrency
import com.tangem.domain.visa.model.VisaTxDetails
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.domain.visa.repository.VisaRepository
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
internal class DummyVisaRepository : VisaRepository {
override suspend fun getVisaCurrency(userWalletId: UserWalletId, isRefresh: Boolean): VisaCurrency {
TODO("Not implemented for this build type")
}
override suspend fun getTxHistory(
userWalletId: UserWalletId,
pageSize: Int,
isRefresh: Boolean,
): Flow<PagingData<VisaTxHistoryItem>> {
TODO("Not implemented for this build type")
}
override suspend fun getTxDetails(userWalletId: UserWalletId, txId: String): VisaTxDetails {
TODO("Not implemented for this build type")
}
}

View file

@ -0,0 +1,52 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
class MockVisaActivationRepository @AssistedInject constructor(
@Assisted private val visaCardId: VisaCardId,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState {
return VisaActivationRemoteState.PaymentAccountDeploying
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState {
return VisaActivationRemoteState.PaymentAccountDeploying
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet {
return VisaDataToSignByCardWallet(
request = request,
hashToSign = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
)
}
override suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet {
return VisaDataToSignByCustomerWallet(
request = request,
CryptoUtils.generateRandomBytes(length = 32).toHexString(),
)
}
override suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet) {}
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) {}
override suspend fun sendPinCode(pinCode: VisaEncryptedPinCode) {}
@AssistedFactory
interface Factory : VisaActivationRepository.Factory {
override fun create(cardId: VisaCardId): MockVisaActivationRepository
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.visa.converter
import com.squareup.moshi.Moshi
import com.tangem.data.visa.model.AccessCodeData
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.utils.converter.Converter
import okio.ByteString.Companion.decodeBase64
import javax.inject.Inject
import javax.inject.Singleton
private const val JWT_PAYLOAD_INDEX = 1
@Singleton
internal class AccessCodeDataConverter @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
) : Converter<VisaAuthTokens, AccessCodeData> {
private val adapter = moshi.adapter(AccessCodeData::class.java)
override fun convert(value: VisaAuthTokens): AccessCodeData {
val payloadBase64 = value.accessToken.split(".").getOrNull(JWT_PAYLOAD_INDEX)
val decodedString = payloadBase64?.decodeBase64()?.utf8()
val data = decodedString?.let { adapter.fromJson(it) }
requireNotNull(data) { "Invalid access token" }
return data
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.data.visa.converter
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.utils.converter.Converter
import javax.inject.Inject
class VisaActivationStatusConverter @Inject constructor() :
Converter<CardActivationRemoteStateResponse, VisaActivationRemoteState> {
override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState {
// TODO Will be implemented in the future
return VisaActivationRemoteState.Activated
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.data.visa.di
import javax.inject.Qualifier
@Qualifier
internal annotation class ImplementedVisaRepository

View file

@ -0,0 +1,16 @@
package com.tangem.data.visa.di
import com.tangem.domain.visa.repository.VisaRepository
import dagger.BindsOptionalOf
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface ImplementedVisaRepositoryModule {
@BindsOptionalOf
@ImplementedVisaRepository
fun bindImplementedVisaRepository(): VisaRepository
}

View file

@ -0,0 +1,51 @@
package com.tangem.data.visa.di
import com.tangem.data.visa.DefaultVisaAuthRepository
import com.tangem.data.visa.DummyVisaRepository
import com.tangem.data.visa.MockVisaActivationRepository
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.repository.VisaRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.util.Optional
import javax.inject.Singleton
import kotlin.jvm.optionals.getOrNull
@Module
@InstallIn(SingletonComponent::class)
internal object VisaDataModule {
@Provides
@Singleton
fun provideVisaRepository(
@ImplementedVisaRepository implementedVisaRepository: Optional<VisaRepository>,
): VisaRepository {
return implementedVisaRepository.getOrNull() ?: DummyVisaRepository()
}
}
@Module
@InstallIn(SingletonComponent::class)
internal interface VisaDataBindsModule {
@Binds
@Singleton
fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository
// @Binds
// @Singleton
// fun bindVisaActivationRepositoryFactory(
// repository: DefaultVisaActivationRepository.Factory,
// ): VisaActivationRepository.Factory
// Mocked
@Binds
@Singleton
fun bindVisaActivationRepositoryFactory(
repository: MockVisaActivationRepository.Factory,
): VisaActivationRepository.Factory
}

View file

@ -0,0 +1,10 @@
package com.tangem.data.visa.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class AccessCodeData(
@Json(name = "pid") val productInstanceId: String,
@Json(name = "sub") val customerId: String,
)