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

View file

@ -1,48 +1,25 @@
plugins {
id("com.android.library")
kotlin("android")
kotlin("kapt")
id("com.google.dagger.hilt.android")
}
android {
defaultConfig {
compileSdk = AppConfig.compileSdkVersion
minSdk = AppConfig.minSdkVersion
targetSdk = AppConfig.targetSdkVersion
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
isCoreLibraryDesugaringEnabled = false
}
buildTypes {
create("debug_beta") {
initWith(getByName("release"))
BuildConfigFieldFactory(
fields = listOf(
Field.Environment("release"),
Field.TestActionEnabled(true),
Field.LogEnabled(true),
),
builder = ::buildConfigField,
).create()
}
}
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
dependencies {
/** DI */
implementation(Library.hilt)
kapt(Library.hiltKapt)
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/** Core shouldn't depends on core, but in case with utils and logging its necessary */
implementation(project(":core:utils"))
/** Analytics - Models */
api(projects.core.analytics.models)
/** Domain */
implementation(projects.domain.analytics)
implementation(projects.domain.models)
/** Other */
implementation(deps.kotlin.coroutines)
/** Core shouldn't depend on core, but in case with utils and logging its necessary */
implementation(projects.core.utils)
}

1
core/analytics/models/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,4 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}

View file

@ -1,4 +1,4 @@
package com.tangem.core.analytics
package com.tangem.core.analytics.models
/**
[REDACTED_AUTHOR]
@ -8,5 +8,7 @@ open class AnalyticsEvent(
val event: String,
var params: Map<String, String> = mapOf(),
val error: Throwable? = null,
var filterData: Any? = null,
)
) {
val id: String = "[$category] $event"
}

View file

@ -0,0 +1,214 @@
package com.tangem.core.analytics.models
sealed class AnalyticsParam {
sealed class CardBalanceState(val value: String) {
data object Empty : CardBalanceState("Empty")
data object Full : CardBalanceState("Full")
data object CustomToken : CardBalanceState("Custom Token")
data object BlockchainError : CardBalanceState("Blockchain Error")
data object NoRate : CardBalanceState("No Rate")
companion object
}
sealed class TokenBalanceState(val value: String) {
data object Empty : TokenBalanceState("Empty")
data object Full : TokenBalanceState("Full")
}
sealed class RateApp(val value: String) {
data object Liked : RateApp("Liked")
data object Disliked : RateApp("Disliked")
data object Closed : RateApp("Close")
}
enum class OnOffState(val value: String) {
On("On"),
Off("Off"),
;
companion object {
fun from(enabled: Boolean): String {
val state = if (enabled) On else Off
return state.value
}
}
}
sealed class OrganizeSortType(val value: String) {
data object ByBalance : OrganizeSortType("By Balance")
data object Manually : OrganizeSortType("Manually")
}
sealed class UserCode(val value: String) {
data object AccessCode : UserCode("Access Code")
data object Passcode : UserCode("Passcode")
}
sealed class AccessCodeRecoveryStatus(val value: String) {
val key: String = "Status"
data object Enabled : AccessCodeRecoveryStatus("Enabled")
data object Disabled : AccessCodeRecoveryStatus("Disabled")
companion object {
fun from(enabled: Boolean): AccessCodeRecoveryStatus {
return if (enabled) Enabled else Disabled
}
}
}
sealed class Error(val value: String) {
data object App : Error("App Error")
data object CardSdk : Error("Card Sdk Error")
data object BlockchainSdk : Error("Blockchain Sdk Error")
}
sealed class ScreensSources(val value: String) {
data object Settings : ScreensSources("Settings")
data object Main : ScreensSources("Main")
data object SignIn : ScreensSources("Sign In")
data object Send : ScreensSources("Send")
data object Intro : ScreensSources("Introduction")
data object MyWallets : ScreensSources("My Wallets")
data object Token : ScreensSources("Token")
data object Stories : ScreensSources("Stories")
data object Buy : ScreensSources("Buy")
data object Swap : ScreensSources("Swap")
data object Sell : ScreensSources("Sell")
data object Backup : ScreensSources("Backup")
data object Onboarding : ScreensSources("Onboarding")
data object LongTap : ScreensSources("Long Tap")
data object Markets : ScreensSources("Markets")
}
sealed class TxSentFrom(val value: String) {
data class Send(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
) : TxSentFrom("Send"), TxData
data class Swap(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
) : TxSentFrom("Swap"), TxData
data class Staking(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
) : TxSentFrom("Staking"), TxData
data class Approve(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
val permissionType: String,
) : TxSentFrom("Approve"), TxData
data object WalletConnect : TxSentFrom("WalletConnect")
data object Sell : TxSentFrom("Sell")
}
sealed interface TxData {
val blockchain: String
val token: String
val feeType: FeeType
}
sealed class FeeType(val value: String) {
data object Fixed : FeeType("Fixed")
data object Min : FeeType("Min")
data object Normal : FeeType("Normal")
data object Max : FeeType("Max")
data object Custom : FeeType("Custom")
companion object {
fun fromString(feeType: String): FeeType {
return when (feeType) {
Min.value -> Min
Normal.value -> Normal
Max.value -> Max
Fixed.value -> Fixed
else -> Fixed
}
}
}
}
sealed class WalletCreationType(val value: String) {
data object PrivateKey : WalletCreationType("Private key")
data object NewSeed : WalletCreationType("New seed")
data object SeedImport : WalletCreationType("Seed import")
}
sealed class WalletType(val value: String) {
data object MultiCurrency : WalletType(value = "Multicurrency")
class SingleCurrency(currencyName: String) : WalletType(currencyName)
}
enum class Validation(val value: String) {
OK(value = "Ok"),
ERROR(value = "Error"),
;
companion object {
fun from(isValid: Boolean): String {
val status = if (isValid) OK else ERROR
return status.value
}
}
}
enum class Status(val value: String) {
Success(value = "Success"),
Error(value = "Error"),
Pending(value = "Pending"),
}
companion object Key {
const val BLOCKCHAIN = "blockchain"
const val TOKEN_PARAM = "Token"
const val SOURCE = "Source"
const val BALANCE = "Balance"
const val TOKENS_COUNT = "Tokens Count"
const val STATE = "State"
const val BATCH = "Batch"
const val TYPE = "Type"
const val FEE_TYPE = "Fee Type"
const val WALLET_FORM = "WalletForm"
const val PERMISSION_TYPE = "Permission Type"
const val PRODUCT_TYPE = "Product Type"
const val FIRMWARE = "Firmware"
const val CURRENCY = "Currency"
const val ERROR_DESCRIPTION = "Error Description"
const val ERROR_CODE = "Error Code"
const val ERROR_KEY = "Error Key"
const val ERROR_TYPE = "Error Type"
const val ERROR_MESSAGE = "Error Message"
const val CREATION_TYPE = "Creation type"
const val DAPP_NAME = "DApp Name"
const val DAPP_URL = "DApp Url"
const val METHOD_NAME = "Method Name"
const val VALIDATION = "Validation"
const val BLOCKCHAIN_EXCEPTION_HOST = "exception_host"
const val BLOCKCHAIN_SELECTED_HOST = "selected_host"
const val INPUT = "Input"
const val COUNT = "Count"
const val DERIVATION = "Derivation"
const val STATUS = "Status"
const val PROVIDER = "Provider"
const val PLACE = "Place"
const val RESIDENCE = "Residence"
const val PAYMENT_METHOD = "Payment Method"
const val WATCHED = "Watched"
}
}

View file

@ -0,0 +1,87 @@
package com.tangem.core.analytics.models
sealed class Basic(
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent("Basic", event, params, error) {
class CardWasScanned(
source: AnalyticsParam.ScreensSources,
) : Basic(
event = "Card Was Scanned",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
),
)
class SignedIn(
currency: AnalyticsParam.WalletType,
batch: String,
signInType: SignInType,
walletsCount: String,
hasBackup: Boolean?,
) : Basic(
event = "Signed in",
params = buildMap {
put(AnalyticsParam.CURRENCY, currency.value)
put(AnalyticsParam.BATCH, batch)
put("Sign in type", signInType.name)
put("Wallets Count", walletsCount)
if (hasBackup != null) {
put("Backuped", if (hasBackup) "Yes" else "No")
}
},
) {
enum class SignInType {
Card, Biometric
}
}
class ToppedUp(userWalletId: String, currency: AnalyticsParam.WalletType) :
Basic(
event = "Topped up",
params = mapOf(AnalyticsParam.CURRENCY to currency.value),
),
OneTimeAnalyticsEvent {
override val oneTimeEventId: String = id + userWalletId
}
class TransactionSent(sentFrom: AnalyticsParam.TxSentFrom, memoType: MemoType) :
Basic(
event = "Transaction sent",
params = buildMap {
this[AnalyticsParam.SOURCE] = sentFrom.value
if (sentFrom is AnalyticsParam.TxData) {
this[AnalyticsParam.BLOCKCHAIN] = sentFrom.blockchain
this[AnalyticsParam.TOKEN_PARAM] = sentFrom.token
this[AnalyticsParam.FEE_TYPE] = sentFrom.feeType.value
}
if (sentFrom is AnalyticsParam.TxSentFrom.Approve) {
this[AnalyticsParam.PERMISSION_TYPE] = sentFrom.permissionType
}
this["Memo"] = memoType.name
},
) {
enum class MemoType {
Empty, Full, Null
}
enum class WalletForm {
Card, Ring
}
}
class ScanError(error: Throwable) : Basic(
event = "Scan",
error = error,
)
class ButtonSupport(source: AnalyticsParam.ScreensSources) : Basic(
event = "Request Support",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
),
)
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.analytics.models
interface OneTimeAnalyticsEvent {
val oneTimeEventId: String
}

View file

@ -0,0 +1,86 @@
package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
/**
* Main screen analytics event
*
* @param event event name
* @param params params
*/
sealed class MainScreenAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Main Screen", event = event, params = params) {
class EnableBiometrics(state: AnalyticsParam.OnOffState) : MainScreenAnalyticsEvent(
event = "Enable Biometric",
params = mapOf("State" to state.value),
)
// region Action Buttons feature
data class ButtonBuy(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent(
event = "Button - Buy",
params = mapOf(AnalyticsParam.STATUS to status.value),
)
data class ButtonSwap(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent(
event = "Button - Swap",
params = mapOf(AnalyticsParam.STATUS to status.value),
)
data class ButtonSell(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent(
event = "Button - Sell",
params = mapOf(AnalyticsParam.STATUS to status.value),
)
data object BuyScreenOpened : MainScreenAnalyticsEvent(event = "Buy Screen Opened")
data object SwapScreenOpened : MainScreenAnalyticsEvent(event = "Swap Screen Opened")
data object SellScreenOpened : MainScreenAnalyticsEvent(event = "Sell Screen Opened")
data class BuyTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Buy Token Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class SellTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Sell Token Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class SwapTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Swap Token Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class ReceiveTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Receive Token Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class RemoveTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Remove Button Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class ButtonClose(val source: AnalyticsParam.ScreensSources) : MainScreenAnalyticsEvent(
event = "Button - Close",
params = mapOf(AnalyticsParam.SOURCE to source.value),
)
data class HotTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent(
event = "Hot Token Clicked",
params = mapOf(TOKEN_PARAM to currencySymbol),
)
data class HotTokenError(val errorCode: String) : MainScreenAnalyticsEvent(
event = "Hot Token Error",
params = mapOf(ERROR_CODE to errorCode),
)
// endregion
}

View file

@ -0,0 +1,24 @@
package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed class OnboardingAnalyticsEvent(
category: String,
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category, event, params) {
sealed class Onboarding(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingAnalyticsEvent(category = "Error", event = event, params = params) {
data class OfflineAttestationFailed(
val source: AnalyticsParam.ScreensSources,
) : Onboarding(
event = "Offline Attestation Failed",
params = mapOf(AnalyticsParam.SOURCE to source.value),
)
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
/**
* Tech analytics event
*
* @param event event name
* @param params params
*/
sealed class TechAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Tech", event = event, params = params) {
class WindowObscured(state: ObscuredState) : TechAnalyticsEvent(
event = "Window Obscured",
params = mapOf("State" to state.name),
) {
enum class ObscuredState {
PARTIALLY,
FULLY,
}
}
class KeyboardIdentifier(id: String, packageName: String?, isTrusted: Boolean) : TechAnalyticsEvent(
event = "Keyboard Identifier",
params = buildMap {
put("Id", id)
packageName?.let {
put("Package", it)
put("GPUrl", "https://play.google.com/store/apps/details?id=$packageName")
}
put("isTrusted", isTrusted.toString())
},
)
}

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.core.analytics" />

View file

@ -1,24 +1,19 @@
package com.tangem.core.analytics
import com.tangem.core.analytics.api.AnalyticsEventFilter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsFilterHolder
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.core.analytics.api.AnalyticsHandlerHolder
import com.tangem.core.analytics.api.ParamsInterceptor
import com.tangem.core.analytics.api.ParamsInterceptorHolder
import com.tangem.core.analytics.api.*
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/**
[REDACTED_AUTHOR]
*/
interface GlobalAnalyticsEventHandler : AnalyticsEventHandler,
interface GlobalAnalyticsEventHandler :
AnalyticsEventHandler,
AnalyticsHandlerHolder,
AnalyticsFilterHolder,
ParamsInterceptorHolder
@ -28,8 +23,9 @@ object Analytics : GlobalAnalyticsEventHandler {
private val analyticsScope: CoroutineScope by lazy { createScope() }
private val handlers = mutableMapOf<String, AnalyticsHandler>()
private val paramsInterceptors = mutableMapOf<String, ParamsInterceptor>()
private val paramsInterceptors = ConcurrentHashMap<String, ParamsInterceptor>()
private val analyticsFilters = mutableSetOf<AnalyticsEventFilter>()
private val analyticsMutex = Mutex()
private val analyticsHandlers: List<AnalyticsHandler>
get() = handlers.values.toList()
@ -54,8 +50,8 @@ object Analytics : GlobalAnalyticsEventHandler {
paramsInterceptors[interceptor.id()] = interceptor
}
override fun removeParamsInterceptor(interceptor: ParamsInterceptor): ParamsInterceptor? {
return paramsInterceptors.remove(interceptor.id())
override fun removeParamsInterceptor(interceptorId: String): ParamsInterceptor? {
return paramsInterceptors.remove(interceptorId)
}
override fun send(event: AnalyticsEvent) {
@ -63,23 +59,26 @@ object Analytics : GlobalAnalyticsEventHandler {
event.params = applyParamsInterceptors(event)
val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(event) }
when {
eventFilter == null -> analyticsHandlers.forEach { handler -> handler.send(event) }
eventFilter.canBeSent(event) -> {
analyticsHandlers
.filter { handler -> eventFilter.canBeConsumedByHandler(handler, event) }
.forEach { handler -> handler.send(event) }
analyticsMutex.withLock {
when {
eventFilter == null -> analyticsHandlers.forEach { handler -> handler.send(event) }
eventFilter.canBeSent(event) -> {
analyticsHandlers
.filter { handler -> eventFilter.canBeConsumedByHandler(handler, event) }
.forEach { handler -> handler.send(event) }
}
}
}
}
}
private fun applyParamsInterceptors(event: AnalyticsEvent): MutableMap<String, String> {
private suspend fun applyParamsInterceptors(event: AnalyticsEvent): MutableMap<String, String> {
val interceptedParams = event.params.toMutableMap()
paramsInterceptors.values
.filter { it.canBeAppliedTo(event) }
.forEach { it.intercept(interceptedParams) }
analyticsMutex.withLock {
paramsInterceptors.values
.filter { it.canBeAppliedTo(event) }
.forEach { it.intercept(interceptedParams) }
}
return interceptedParams
}

View file

@ -0,0 +1,8 @@
package com.tangem.core.analytics
interface AppInstanceIdProvider {
suspend fun getAppInstanceId(): String?
fun getAppInstanceIdSync(): String?
}

View file

@ -0,0 +1,11 @@
package com.tangem.core.analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
class DummyAnalyticsEventHandler : AnalyticsEventHandler {
override fun send(event: AnalyticsEvent) {
/* no-op */
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.analytics
class DummyAppInstanceIdProvider : AppInstanceIdProvider {
override suspend fun getAppInstanceId(): String? {
return null
}
override fun getAppInstanceIdSync(): String? {
return null
}
}

View file

@ -4,15 +4,9 @@ package com.tangem.core.analytics.api
[REDACTED_AUTHOR]
*/
interface EventLogger {
fun logEvent(
event: String,
params: Map<String, String> = emptyMap(),
)
fun logEvent(event: String, params: Map<String, String> = emptyMap())
}
interface ErrorEventLogger {
fun logErrorEvent(
error: Throwable,
params: Map<String, String> = emptyMap(),
)
fun logErrorEvent(error: Throwable, params: Map<String, String> = emptyMap())
}

View file

@ -1,6 +1,6 @@
package com.tangem.core.analytics.api
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsEvent
/**
[REDACTED_AUTHOR]
@ -16,7 +16,7 @@ interface AnalyticsEventFilter {
* An internal filter check that, on external or internal conditions, recognizes the possibility of
* sending an event.
*/
fun canBeSent(event: AnalyticsEvent): Boolean
suspend fun canBeSent(event: AnalyticsEvent): Boolean
/**
* Performs a check to see if the event can be dispatched by a specific handler

View file

@ -1,6 +1,6 @@
package com.tangem.core.analytics.api
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsEvent
/**
[REDACTED_AUTHOR]
@ -12,20 +12,15 @@ interface AnalyticsEventHandler {
interface AnalyticsHandler : AnalyticsEventHandler {
fun id(): String
fun send(event: String, params: Map<String, String> = emptyMap())
fun send(eventId: String, params: Map<String, String> = emptyMap())
override fun send(event: AnalyticsEvent) {
send(prepareEventString(event), event.params)
send(event.id, event.params)
}
fun prepareEventString(event: AnalyticsEvent): String = "[${event.category}] ${event.event}"
}
interface ErrorEventHandler {
fun send(
error: Throwable,
params: Map<String, String> = emptyMap(),
)
fun send(error: Throwable, params: Map<String, String> = emptyMap())
}
interface AnalyticsHandlerHolder {

View file

@ -1,6 +1,6 @@
package com.tangem.core.analytics.api
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsEvent
/**
[REDACTED_AUTHOR]
@ -13,5 +13,5 @@ interface ParamsInterceptor {
interface ParamsInterceptorHolder {
fun addParamsInterceptor(interceptor: ParamsInterceptor)
fun removeParamsInterceptor(interceptor: ParamsInterceptor): ParamsInterceptor?
fun removeParamsInterceptor(interceptorId: String): ParamsInterceptor?
}

View file

@ -2,6 +2,9 @@ package com.tangem.core.analytics.di
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.ParamsInterceptorHolder
import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.domain.analytics.repository.AnalyticsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -10,11 +13,22 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class AnalyticsModule {
internal object AnalyticsModule {
@Singleton
@Provides
fun provideAnalyticsHandler(): AnalyticsEventHandler {
return Analytics // todo replace after refactoring calling Analytics in whole project
}
@Singleton
@Provides
fun provideParamsInterceptorHolder(): ParamsInterceptorHolder {
return Analytics // todo replace after refactoring calling Analytics in whole project
}
@Provides
fun provideOneTimeEventFilter(analyticsRepository: AnalyticsRepository): OneTimeEventFilter {
return OneTimeEventFilter(analyticsRepository)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.core.analytics.filter
import com.tangem.core.analytics.api.AnalyticsEventFilter
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.OneTimeAnalyticsEvent
import com.tangem.domain.analytics.repository.AnalyticsRepository
class OneTimeEventFilter(
private val analyticsRepository: AnalyticsRepository,
) : AnalyticsEventFilter {
override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is OneTimeAnalyticsEvent
override suspend fun canBeSent(event: AnalyticsEvent): Boolean {
if (event !is OneTimeAnalyticsEvent) return true
val isSent = analyticsRepository.checkIsEventSent(event.oneTimeEventId)
if (!isSent) {
analyticsRepository.setIsEventSent(event.oneTimeEventId)
}
return !isSent
}
override fun canBeConsumedByHandler(handler: AnalyticsHandler, event: AnalyticsEvent): Boolean {
return canBeAppliedTo(event)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.core.analytics.utils
import com.tangem.domain.models.scan.ScanResponse
/**
[REDACTED_AUTHOR]
*/
interface AnalyticsContextProxy {
fun setContext(scanResponse: ScanResponse)
fun eraseContext()
fun addContext(scanResponse: ScanResponse)
fun removeContext()
}

1
core/config-toggles/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,37 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.core.configtoggle"
}
dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Local storages */
implementation(deps.androidx.datastore)
/** Other libraries */
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
kaptForObfuscatingVariants(deps.moshi.kotlin.codegen)
/** Core modules */
implementation(projects.core.datasource)
implementation(projects.core.utils)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -0,0 +1,30 @@
[
{
"name": "",
"version": "undefined"
},
{
"name": "NEXA",
"version": "undefined"
},
{
"name": "NEXA/test",
"version": "undefined"
},
{
"name": "vanar-chain",
"version": "undefined"
},
{
"name": "sonic",
"version": "5.21.0"
},
{
"name": "apechain",
"version": "5.21.0"
},
{
"name": "alephium",
"version": "5.21.0"
}
]

View file

@ -0,0 +1,50 @@
[
{
"name": "NEW_CARD_SCANNING_ENABLED",
"version": "undefined"
},
{
"name": "WC_SOLANA_TX_SIGN_ENABLED",
"version": "5.18.0"
},
{
"name": "IS_ETHEREUM_EIP_1559_ENABLED",
"version": "5.17.0"
},
{
"name": "ONRAMP_ENABLED",
"version": "undefined"
},
{
"name": "MAIN_ACTION_BUTTONS_ENABLED",
"version": "5.19.0"
},
{
"name": "ONBOARDING_CODE_REFACTORING_ENABLED",
"version": "5.22.0"
},
{
"name": "NAVIGATION_REFACTORING",
"version": "undefined"
},
{
"name": "SWAP_STORIES_ENABLED",
"version": "5.21.0"
},
{
"name": "VISA_ONBOARDING_ENABLED",
"version": "undefined"
},
{
"name": "ONRAMP_HOT_TOKENS_ENABLED",
"version": "5.21.0"
},
{
"name": "STAKING_TON_ENABLED",
"version": "undefined"
},
{
"name": "BALANCES_CACHING_ENABLED",
"version": "5.21.0"
}
]

View file

@ -0,0 +1,8 @@
package com.tangem.core.configtoggle.blockchain
interface ExcludedBlockchainsManager {
val excludedBlockchainsIds: Set<String>
suspend fun init()
}

View file

@ -0,0 +1,10 @@
package com.tangem.core.configtoggle.blockchain
interface MutableExcludedBlockchainsManager : ExcludedBlockchainsManager {
suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean)
fun isMatchLocalConfig(): Boolean
suspend fun recoverLocalConfig()
}

View file

@ -0,0 +1,80 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.storeObjectMap
internal class DefaultExcludedBlockchainsManager(
private val localTogglesStorage: TogglesStorage,
private val appPreferencesStore: AppPreferencesStore,
private val versionProvider: VersionProvider,
) : MutableExcludedBlockchainsManager {
private var isInitialized: Boolean = false
private lateinit var currentExcludedBlockchains: MutableMap<String, Boolean>
private lateinit var localExcludedBlockchains: Map<String, Boolean>
override val excludedBlockchainsIds: Set<String>
get() {
if (!isInitialized) error("ExcludedBlockchainsManager is not initialized")
return currentExcludedBlockchains
.filterValues { it }
.keys
}
override suspend fun init() {
localTogglesStorage.populate(path = "configs/excluded_blockchains_config")
val storedExcludedBlockchainsIds = appPreferencesStore.getObjectMapSync<Boolean>(
key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY,
)
localExcludedBlockchains = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty())
.mapValues { (_, isIncluded) -> !isIncluded }
currentExcludedBlockchains = (localExcludedBlockchains.keys + storedExcludedBlockchainsIds.keys)
.fold(mutableMapOf()) { acc, blockchainId ->
val isExcluded = storedExcludedBlockchainsIds[blockchainId] ?: localExcludedBlockchains[blockchainId]
requireNotNull(isExcluded) {
"Unable to find $blockchainId in local or stored excluded blockchains"
}
acc[blockchainId] = isExcluded
acc
}
isInitialized = true
}
override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) {
currentExcludedBlockchains[mainnetId] = isExcluded
storeCurrent()
}
override fun isMatchLocalConfig(): Boolean {
return currentExcludedBlockchains == localExcludedBlockchains
}
override suspend fun recoverLocalConfig() {
currentExcludedBlockchains = localExcludedBlockchains.toMutableMap()
storeCurrent()
}
private suspend fun storeCurrent() {
appPreferencesStore.storeObjectMap(
key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY,
value = currentExcludedBlockchains,
)
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.core.configtoggle.di
import android.content.Context
import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.DefaultExcludedBlockchainsManager
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ExcludedBlockchainsManagerModule {
@Provides
@Singleton
fun provideExcludedBlockchainsManager(
@ApplicationContext context: Context,
assetLoader: AssetLoader,
appPreferencesStore: AppPreferencesStore,
): ExcludedBlockchainsManager {
val localTogglesStorage = LocalTogglesStorage(assetLoader)
val versionProvider = DefaultVersionProvider(context)
return DefaultExcludedBlockchainsManager(
localTogglesStorage,
appPreferencesStore,
versionProvider,
)
}
@Provides
@Singleton
fun provideMutableExcludedBlockchainsManager(
manager: ExcludedBlockchainsManager,
): MutableExcludedBlockchainsManager? {
if (!BuildConfig.TESTER_MENU_ENABLED) return null
return manager as MutableExcludedBlockchainsManager
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.core.configtoggle.di
import android.content.Context
import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object FeatureTogglesManagerModule {
@Provides
@Singleton
fun provideFeatureTogglesManager(
@ApplicationContext context: Context,
assetLoader: AssetLoader,
appPreferencesStore: AppPreferencesStore,
): FeatureTogglesManager {
val localTogglesStorage = LocalTogglesStorage(assetLoader)
val versionProvider = DefaultVersionProvider(context)
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
appPreferencesStore = appPreferencesStore,
versionProvider = versionProvider,
)
} else {
ProdFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
versionProvider = versionProvider,
)
}
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.core.configtoggle.feature
/**
* Component for getting information about the availability of feature toggles
*
[REDACTED_AUTHOR]
*/
interface FeatureTogglesManager {
/** Initialize manager */
suspend fun init()
/** Check feature toggle availability by name [name] */
fun isFeatureEnabled(name: String): Boolean
}

View file

@ -0,0 +1,21 @@
package com.tangem.core.configtoggle.feature
/**
* Component for change information about the availability of feature toggles
*
[REDACTED_AUTHOR]
*/
interface MutableFeatureTogglesManager : FeatureTogglesManager {
/** Check if the current state of the feature toggles matches the local config state. */
fun isMatchLocalConfig(): Boolean
/** Get feature toggles */
fun getFeatureToggles(): Map<String, Boolean>
/** Change availability [isEnabled] of toggle with name [name] */
suspend fun changeToggle(name: String, isEnabled: Boolean)
/** Recover local config state */
suspend fun recoverLocalConfig()
}

View file

@ -0,0 +1,74 @@
package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import kotlin.properties.Delegates
/**
* Feature toggles manager implementation in DEV build
*
* @property localTogglesStorage local feature toggles storage
* @property appPreferencesStore application local store
* @property versionProvider application version provider
*/
internal class DevFeatureTogglesManager(
private val localTogglesStorage: TogglesStorage,
private val appPreferencesStore: AppPreferencesStore,
private val versionProvider: VersionProvider,
) : MutableFeatureTogglesManager {
private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
private var localFeatureTogglesMap: Map<String, Boolean> by Delegates.notNull()
override suspend fun init() {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull<Map<String, Boolean>>(
key = PreferencesKeys.FEATURE_TOGGLES_KEY,
) ?: emptyMap()
val localFeatureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty())
localFeatureTogglesMap = localFeatureToggles
featureTogglesMap = localFeatureToggles
.mapValues { resultToggle ->
savedFeatureToggles[resultToggle.key] ?: resultToggle.value
}
.toMutableMap()
}
override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap[name] ?: false
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == localFeatureTogglesMap
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap
override suspend fun changeToggle(name: String, isEnabled: Boolean) {
featureTogglesMap[name] ?: return
featureTogglesMap[name] = isEnabled
appPreferencesStore.storeFeatureToggles(value = featureTogglesMap)
}
override suspend fun recoverLocalConfig() {
featureTogglesMap = localFeatureTogglesMap.toMutableMap()
appPreferencesStore.storeFeatureToggles(value = localFeatureTogglesMap)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setFeatureToggles(map: MutableMap<String, Boolean>) {
featureTogglesMap = map
}
private suspend fun AppPreferencesStore.storeFeatureToggles(value: Map<String, Boolean>) {
storeObject(PreferencesKeys.FEATURE_TOGGLES_KEY, value)
}
}

View file

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

View file

@ -0,0 +1,38 @@
package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import kotlin.properties.Delegates
/**
* Feature toggles manager implementation in PROD build
*
* @property localTogglesStorage local feature toggles storage
* @property versionProvider application version provider
*/
internal class ProdFeatureTogglesManager(
private val localTogglesStorage: TogglesStorage,
private val versionProvider: VersionProvider,
) : FeatureTogglesManager {
private var featureToggles: Map<String, Boolean> by Delegates.notNull()
override suspend fun init() {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
featureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get() ?: "")
}
override fun isFeatureEnabled(name: String): Boolean = featureToggles[name] ?: false
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun getProdFeatureToggles() = featureToggles
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setProdFeatureToggles(map: Map<String, Boolean>) {
featureToggles = map
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.core.configtoggle.storage
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Data model with information about config toggle
*
* @property name toggle name
* @property version version in which the toggle will be enabled
*
* IMPORTANT: if the version is "undefined", it means that toggle is disabled!
*
[REDACTED_AUTHOR]
*/
@JsonClass(generateAdapter = true)
internal data class ConfigToggle(
@Json(name = "name") val name: String,
@Json(name = "version") val version: String,
)

View file

@ -0,0 +1,24 @@
package com.tangem.core.configtoggle.storage
import com.tangem.datasource.asset.loader.AssetLoader
import kotlin.properties.Delegates
/**
* Storage implementation for storing local feature toggles.
* Feature toggles are declared in file [LOCAL_CONFIG_PATH].
*
* @property assetLoader asset loader
*
[REDACTED_AUTHOR]
*/
internal class LocalTogglesStorage(
private val assetLoader: AssetLoader,
) : TogglesStorage {
override var toggles: List<ConfigToggle> by Delegates.notNull()
private set
override suspend fun populate(path: String) {
toggles = assetLoader.loadList<ConfigToggle>(path)
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.core.configtoggle.storage
/**
* Component that initializes and stores a list of feature toggles
*
[REDACTED_AUTHOR]
*/
internal interface TogglesStorage {
/** List of feature toggles */
val toggles: List<ConfigToggle>
/** Populate the storage with toggles */
suspend fun populate(path: String)
}

View file

@ -0,0 +1,13 @@
package com.tangem.core.configtoggle.utils
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.version.VersionAvailabilityContract
internal fun List<ConfigToggle>.associateToggles(currentVersion: String): Map<String, Boolean> {
return associate { localToggle ->
Pair(
first = localToggle.name,
second = VersionAvailabilityContract(currentVersion, localToggle.version),
)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.core.configtoggle.version
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.tangem.utils.StringsSigns.MINUS
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
/**
* Implementation of application version provider
*
* @property context application context
*/
internal class DefaultVersionProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : VersionProvider {
override fun get(): String? {
return runCatching { getVersionName().substringBefore(VERSION_NAME_DELIMITER) }
.fold(onSuccess = { it }, onFailure = { null })
}
private fun getVersionName(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager
.getPackageInfo(
context.packageName,
PackageManager.PackageInfoFlags.of(0),
)
.versionName
} else {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}
}
private companion object {
const val VERSION_NAME_DELIMITER = MINUS
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.core.configtoggle.version
import androidx.annotation.VisibleForTesting
import timber.log.Timber
/**
* Presentation of application version (<major>.<minor>.<fix?>).
*
* @param value version value as string
*
[REDACTED_AUTHOR]
*/
internal class Version private constructor(value: String) : Comparable<Version> {
private val major: Int
private val minor: Int
private val fix: Int?
init {
val versions = value.split(VERSION_DELIMITER).map(String::toInt)
major = versions.getVersionValue(index = MAJOR_VERSION_POSITION)
minor = versions.getVersionValue(index = MINOR_VERSION_POSITION)
fix = versions.getOrNull(index = FIX_VERSION_POSITION)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
constructor(major: Int, minor: Int, fix: Int? = null) : this(
value = "$major.$minor${if (fix != null) ".$fix" else ""}",
)
override fun compareTo(other: Version): Int {
var result = major.compareTo(other.major)
if (result == 0) result = minor.compareTo(other.minor)
if (result == 0) {
when {
fix == null && other.fix == null -> result = 0
fix == null && other.fix != null -> result = -1
fix != null && other.fix == null -> result = 1
fix != null && other.fix != null -> result = fix.compareTo(other.fix)
}
}
return result
}
private fun List<Int>.getVersionValue(index: Int): Int {
return getOrNull(index) ?: error("Invalid version")
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun getMajorVersion() = major
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun getMinorVersion() = minor
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun getFixVersion() = fix
companion object {
private const val MAJOR_VERSION_POSITION = 0
private const val MINOR_VERSION_POSITION = 1
private const val FIX_VERSION_POSITION = 2
private const val VERSION_DELIMITER = "."
/**
* Create instance with value [value].
* If [value] doesn't meet all requirements, the function returns null.
*/
fun create(value: String): Version? {
return try {
Version(value)
} catch (exception: Exception) {
Timber.e(exception, "Invalid version - %s", value)
return null
}
}
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.core.configtoggle.version
/**
* Version contract to evaluate availability of feature toggle
*
[REDACTED_AUTHOR]
*/
internal object VersionAvailabilityContract {
private const val DISABLED_FEATURE_TOGGLE_VERSION = "undefined"
/** Evaluate availability of feature toggles using [currentVersion] and [localVersion] */
operator fun invoke(currentVersion: String, localVersion: String): Boolean {
if (localVersion == DISABLED_FEATURE_TOGGLE_VERSION) return false
val current = Version.create(currentVersion) ?: return false
val local = Version.create(localVersion) ?: return false
return current >= local
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.core.configtoggle.version
/** Application version provider */
internal interface VersionProvider {
/** Get application version */
fun get(): String?
}

View file

@ -0,0 +1,71 @@
package com.tangem.core.configtoggle.contract
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.version.VersionAvailabilityContract
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class VersionAvailabilityContractTest {
@Test
fun `local version is undefined`() {
val currentVersion = "0.0.0"
val localVersion = "undefined"
val actual = VersionAvailabilityContract.invoke(currentVersion, localVersion)
Truth.assertThat(actual).isFalse()
}
@Test
fun `invalid current version`() {
val currentVersion = ".0.0"
val localVersion = "0.0.0"
val actual = VersionAvailabilityContract.invoke(currentVersion, localVersion)
Truth.assertThat(actual).isFalse()
}
@Test
fun `invalid local version`() {
val currentVersion = "0.0.0"
val localVersion = ".0.0"
val actual = VersionAvailabilityContract.invoke(currentVersion, localVersion)
Truth.assertThat(actual).isFalse()
}
@Test
fun `current version is greater than local version`() {
val currentVersion = "1.0.0"
val localVersion = "0.0.0"
val actual = VersionAvailabilityContract.invoke(currentVersion, localVersion)
Truth.assertThat(actual).isTrue()
}
@Test
fun `current version is equal to local version`() {
val currentVersion = "0.0.1"
val localVersion = "0.0.1"
val actual = VersionAvailabilityContract.invoke(currentVersion, localVersion)
Truth.assertThat(actual).isTrue()
}
@Test
fun `current version is less than local version`() {
val currentVersion = "0.0.0"
val localVersion = "0.1.0"
val actual = VersionAvailabilityContract.invoke(currentVersion, localVersion)
Truth.assertThat(actual).isFalse()
}
}

View file

@ -0,0 +1,122 @@
package com.tangem.core.configtoggle.contract
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.version.Version
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class VersionTest {
@Test
fun `right versions order`() {
val major = 1
val minor = 2
val fix = 3
val versionString = "$major.$minor.$fix"
val version = Version.create(versionString)
Truth.assertThat(version?.getMajorVersion()).isEqualTo(major)
Truth.assertThat(version?.getMinorVersion()).isEqualTo(minor)
Truth.assertThat(version?.getFixVersion()).isEqualTo(fix)
}
@Test
fun `major version skipped`() {
Truth.assertThat(Version.create(".0.0")).isNull()
}
@Test
fun `minor version skipped`() {
Truth.assertThat(Version.create("0..0")).isNull()
}
@Test
fun `fix version skipped`() {
Truth.assertThat(Version.create("0.0.")).isNull()
}
@Test
fun `optional fix version`() {
val actual = Version.create("0.0")
val expected = Version(major = 0, minor = 0)
Truth.assertThat(actual).isEquivalentAccordingToCompareTo(expected)
}
@Test
fun `full form version`() {
val actual = Version.create("0.0.0")
val expected = Version(major = 0, minor = 0, fix = 0)
Truth.assertThat(actual).isEquivalentAccordingToCompareTo(expected)
}
@Test
fun `compareTo if major version of one is greater than the other`() {
val one = Version.create("1.0.0")
val other = Version(major = 0, minor = 1, fix = 1)
val actual = one?.compareTo(other)
Truth.assertThat(actual).isEqualTo(ONE_IS_GREATER_THAN_OTHER)
}
@Test
fun `compareTo if minor version of one is greater than the other`() {
val one = Version.create("1.1.0")
val other = Version(major = 1, minor = 0, fix = 1)
val actual = one?.compareTo(other)
Truth.assertThat(actual).isEqualTo(ONE_IS_GREATER_THAN_OTHER)
}
@Test
fun `compareTo if fix version of one is greater than the other`() {
val one = Version.create("1.1.1")
val other = Version(major = 1, minor = 1, fix = 0)
val actual = one?.compareTo(other)
Truth.assertThat(actual).isEqualTo(ONE_IS_GREATER_THAN_OTHER)
}
@Test
fun `compareTo if one and other's fix version skipped`() {
val one = Version.create("1.1")
val other = Version(major = 1, minor = 1)
val actual = one?.compareTo(other)
Truth.assertThat(actual).isEqualTo(ONE_IS_EQUAL_TO_OTHER)
}
@Test
fun `compareTo if one's fix version skipped`() {
val one = Version.create("1.1")
val other = Version(major = 1, minor = 1, fix = 0)
val actual = one?.compareTo(other)
Truth.assertThat(actual).isEqualTo(ONE_IS_LESS_THAN_OTHER)
}
@Test
fun `compareTo if other's fix version skipped`() {
val one = Version.create("1.1.0")
val other = Version(major = 1, minor = 1)
val actual = one?.compareTo(other)
Truth.assertThat(actual).isEqualTo(ONE_IS_GREATER_THAN_OTHER)
}
private companion object {
const val ONE_IS_GREATER_THAN_OTHER = 1
const val ONE_IS_EQUAL_TO_OTHER = 0
const val ONE_IS_LESS_THAN_OTHER = -1
}
}

View file

@ -0,0 +1,231 @@
package com.tangem.core.configtoggle.manager
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.collections.set
/**
[REDACTED_AUTHOR]
*/
@SuppressLint("CheckResult")
internal class DevTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val appPreferenceStore = mockk<AppPreferencesStore>(relaxed = true)
private val versionProvider = mockk<VersionProvider>()
private val manager = DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
appPreferencesStore = appPreferenceStore,
versionProvider = versionProvider,
)
@Test
fun `successfully initialize storage if shared prefs kept feature toggles`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns savedFeatureTogglesMap
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if shared prefs kept empty list`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns emptyMap()
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if shared prefs didn't keep feature toggles`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns null
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues(Map.Entry<String, Boolean>::value)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery { appPreferenceStore.getSyncOrNull(PreferencesKeys.FEATURE_TOGGLES_KEY) } returns savedFeatureToggles
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns null
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion = "")
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `get feature availability if feature toggle exists`() {
val featureToggles = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED")
Truth.assertThat(actual).isTrue()
}
@Test
fun `get feature availability if feature toggle doesn't exists`() {
val featureToggles = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "")
Truth.assertThat(actual).isFalse()
}
@Test
fun getFeatureToggles() {
val expected = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(expected)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `change toggle that contains in map`() = runTest {
val changeableToggleName = "INACTIVE_TEST_FEATURE_ENABLED"
val resultMap = mutableMapOf(
changeableToggleName to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(resultMap)
manager.changeToggle(changeableToggleName, true)
resultMap[changeableToggleName] = true
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap)
}
@Test
fun `change toggle that doesn't contains in map`() = runTest {
val resultMap = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(resultMap)
manager.changeToggle("FEATURE_TOGGLE", true)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap)
}
private companion object {
val savedFeatureToggles = """
[
{
"name": "INACTIVE_TEST_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "ACTIVE2_TEST_FEATURE_ENABLED",
"version": "1.0.0"
}
]
""".trimIndent()
val savedFeatureTogglesMap = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
val localFeatureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
}
}

View file

@ -0,0 +1,136 @@
package com.tangem.core.configtoggle.manager
import android.content.pm.PackageManager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class ProdTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val versionProvider = mockk<VersionProvider>()
private val manager = ProdFeatureTogglesManager(localTogglesStorage, versionProvider)
@Test
fun `successfully initialize storage`() = runTest {
val currentVersion = "1.0.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles.associateToggles(currentVersion)
Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } returns null
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(disabledFeatureToggles)
}
@Test
fun `failure initialize storage if localFeatureTogglesStorage throws exception`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } throws IllegalStateException(
"Property featureToggles should be initialized before get.",
)
runCatching { manager.init() }
.onSuccess { throw IllegalStateException("localFeatureToggles shouldn't be initialized") }
.onFailure {
Truth
.assertThat(it)
.hasMessageThat()
.contains("Property featureToggles should be initialized before get.")
Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java)
}
coVerifyOrder { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) }
verifyAll(inverse = true) { versionProvider.get() }
}
@Test
fun `failure initialize storage if versionProvider throws exception`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } throws PackageManager.NameNotFoundException()
runCatching { manager.init() }
.onSuccess { throw IllegalStateException("versionProvider should throws exception") }
.onFailure {
Truth.assertThat(it).isInstanceOf(PackageManager.NameNotFoundException::class.java)
}
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
}
@Test
fun `get feature availability if feature toggle exists`() {
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setProdFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED")
Truth.assertThat(actual).isTrue()
}
@Test
fun `get feature availability if feature toggle doesn't exists`() {
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setProdFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "")
Truth.assertThat(actual).isFalse()
}
private companion object {
val localFeatureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
val disabledFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
}
}

View file

@ -0,0 +1,105 @@
package com.tangem.core.configtoggle.storage
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.asset.reader.AssetReader
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.IOException
/**
[REDACTED_AUTHOR]
*/
@SuppressLint("CheckResult")
internal class LocalTogglesStorageTest {
private val assetReader = mockk<AssetReader>()
private val moshi = mockk<Moshi>()
private val jsonAdapter = mockk<JsonAdapter<List<ConfigToggle>>>()
// Impossible to mockk AssetLoader because it implement inline functions
private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi)
private val storage = LocalTogglesStorage(assetLoader)
@Test
fun `successfully initialize storage`() = runTest {
everyReadingJson() returns json
everyCreatingMoshiAdapter() returns jsonAdapter
everyMappingJson() returns featureToggles
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder {
assetReader.read(CONFIG_FILE_NAME)
jsonAdapter.fromJson(json)
}
Truth.assertThat(storage.toggles).containsExactlyElementsIn(featureToggles)
}
@Test
fun `failure initialize storage if assetReader throws exception`() = runTest {
everyReadingJson() returns json
everyCreatingMoshiAdapter() returns jsonAdapter
everyMappingJson() throws IOException()
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder {
assetReader.read(CONFIG_FILE_NAME)
jsonAdapter.fromJson(json)
}
Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList<ConfigToggle>())
}
@Test
fun `failure initialize storage if jsonAdapter throws exception`() = runTest {
everyReadingJson() throws IOException()
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder { assetReader.read(CONFIG_FILE_NAME) }
verifyAll(inverse = true) { jsonAdapter.fromJson(any<String>()) }
Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList<ConfigToggle>())
}
private fun everyReadingJson() = coEvery { assetReader.read(CONFIG_FILE_NAME) }
private fun everyCreatingMoshiAdapter() = every {
val types = Types.newParameterizedType(List::class.java, ConfigToggle::class.java)
moshi.adapter<List<ConfigToggle>>(types)
}
private fun everyMappingJson() = every { jsonAdapter.fromJson(json) }
private companion object {
const val CONFIG_FILE_NAME = "configs/feature_toggles_config.json"
val json = """
[
{
"name": "INACTIVE_TEST_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "ACTIVE2_TEST_FEATURE_ENABLED",
"version": "1.0.0"
}
]
""".trimIndent()
val featureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
}
}

View file

@ -1,68 +1,87 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
plugins {
id("com.android.library")
kotlin("android")
kotlin("kapt")
id("com.google.dagger.hilt.android")
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.room)
id("configuration")
}
android {
defaultConfig {
compileSdk = AppConfig.compileSdkVersion
minSdk = AppConfig.minSdkVersion
targetSdk = AppConfig.targetSdkVersion
}
namespace = "com.tangem.datasource"
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
isCoreLibraryDesugaringEnabled = false
}
buildTypes {
create("debug_beta") {
initWith(getByName("release"))
BuildConfigFieldFactory(
fields = listOf(
Field.Environment("release"),
Field.TestActionEnabled(true),
Field.LogEnabled(true),
),
builder = ::buildConfigField,
).create()
}
room {
schemaDirectory("$projectDir/schemas")
}
}
dependencies {
/** Project */
implementation(project(":libs:auth"))
implementation(projects.core.analytics)
implementation(projects.core.utils)
implementation(projects.libs.auth)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.core)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.models)
/** Tangem libraries */
implementation(Tangem.cardCore)
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/** DI */
implementation(Library.hilt)
kapt(Library.hiltKapt)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Coroutines */
implementation(Library.coroutine)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
/** Logging */
implementation(Library.timber)
implementation(deps.timber)
/** Network */
implementation(Library.retrofit)
implementation(Library.retrofitMoshiConverter)
implementation(Library.moshi)
implementation(Library.moshiKotlin)
implementation(Library.okHttp)
implementation(Library.okHttpLogging)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.moshi.adapters.ext)
implementation(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.retrofit)
implementation(deps.retrofit.moshi)
kaptForObfuscatingVariants(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
/** Time */
implementation(Library.jodatime)
implementation(deps.jodatime)
/** Security */
implementation(deps.spongecastle.core)
/** Chucker */
debugImplementation(deps.chucker)
debugPGImplementation(deps.chucker)
mockedImplementation(deps.chuckerStub)
externalImplementation(deps.chuckerStub)
internalImplementation(deps.chuckerStub)
releaseImplementation(deps.chuckerStub)
/** Local storages */
implementation(deps.androidx.datastore)
implementation(deps.room.runtime)
implementation(deps.room.ktx)
kapt(deps.room.compiler)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -0,0 +1,304 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "a8a710af25033ee27e5043d001385234",
"entities": [
{
"tableName": "UserWalletEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `artworkUrl` TEXT NOT NULL, `isMultiCurrency` INTEGER NOT NULL, `hasBackupError` INTEGER NOT NULL, `cardsInWallet` TEXT NOT NULL, `ordinalNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artworkUrl",
"columnName": "artworkUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isMultiCurrency",
"columnName": "isMultiCurrency",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasBackupError",
"columnName": "hasBackupError",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cardsInWallet",
"columnName": "cardsInWallet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ordinalNumber",
"columnName": "ordinalNumber",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_UserWalletEntity_id",
"unique": true,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_UserWalletEntity_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_UserWalletEntity_ordinalNumber",
"unique": true,
"columnNames": [
"ordinalNumber"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_UserWalletEntity_ordinalNumber` ON `${TABLE_NAME}` (`ordinalNumber`)"
}
],
"foreignKeys": []
},
{
"tableName": "CryptoCurrenciesAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userWalletId` TEXT NOT NULL, `title` TEXT NOT NULL, `currenciesCount` INTEGER NOT NULL, `isArchived` INTEGER NOT NULL, `ordinalNumber` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`userWalletId`) REFERENCES `UserWalletEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userWalletId",
"columnName": "userWalletId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "currenciesCount",
"columnName": "currenciesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ordinalNumber",
"columnName": "ordinalNumber",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_CryptoCurrenciesAccountEntity_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_CryptoCurrenciesAccountEntity_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_CryptoCurrenciesAccountEntity_userWalletId",
"unique": false,
"columnNames": [
"userWalletId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_CryptoCurrenciesAccountEntity_userWalletId` ON `${TABLE_NAME}` (`userWalletId`)"
}
],
"foreignKeys": [
{
"table": "UserWalletEntity",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userWalletId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "CryptoCurrencyEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `currencyBackendId` TEXT, `networkId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `userWalletId` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `contractAddress` TEXT, `derivationPath` TEXT, FOREIGN KEY(`accountId`) REFERENCES `CryptoCurrenciesAccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userWalletId`) REFERENCES `UserWalletEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currencyBackendId",
"columnName": "currencyBackendId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "networkId",
"columnName": "networkId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userWalletId",
"columnName": "userWalletId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "symbol",
"columnName": "symbol",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "decimals",
"columnName": "decimals",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contractAddress",
"columnName": "contractAddress",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "derivationPath",
"columnName": "derivationPath",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_CryptoCurrencyEntity_currencyBackendId",
"unique": false,
"columnNames": [
"currencyBackendId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_CryptoCurrencyEntity_currencyBackendId` ON `${TABLE_NAME}` (`currencyBackendId`)"
},
{
"name": "index_CryptoCurrencyEntity_networkId",
"unique": false,
"columnNames": [
"networkId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_CryptoCurrencyEntity_networkId` ON `${TABLE_NAME}` (`networkId`)"
},
{
"name": "index_CryptoCurrencyEntity_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_CryptoCurrencyEntity_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_CryptoCurrencyEntity_userWalletId",
"unique": false,
"columnNames": [
"userWalletId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_CryptoCurrencyEntity_userWalletId` ON `${TABLE_NAME}` (`userWalletId`)"
}
],
"foreignKeys": [
{
"table": "CryptoCurrenciesAccountEntity",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
},
{
"table": "UserWalletEntity",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userWalletId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a8a710af25033ee27e5043d001385234')"
]
}
}

View file

@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.datasource" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.common
/**
* Provides auth for tangemTech API
*/
interface AuthProvider {
/**
* Returns authToken for tangem tech api
*/
fun getCardPublicKey(): String
fun getCardId(): String
}

View file

@ -3,6 +3,8 @@ package com.tangem.datasource.api.common
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.common.json.TangemSdkAdapter
import com.tangem.datasource.api.common.adapter.BigDecimalAdapter
import retrofit2.converter.moshi.MoshiConverterFactory
/**
@ -13,8 +15,9 @@ import retrofit2.converter.moshi.MoshiConverterFactory
object MoshiConverter {
val networkMoshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.addLast(KotlinJsonAdapterFactory())
.add(BigDecimalAdapter())
.add(TangemSdkAdapter.ByteArrayAdapter())
.build()
val networkMoshiConverter: MoshiConverterFactory = MoshiConverterFactory.create(networkMoshi)

View file

@ -1,8 +1,10 @@
package com.tangem.datasource.api.common
import android.util.Log
import com.ihsanbal.logging.Level
import com.ihsanbal.logging.LoggingInterceptor
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
@ -22,7 +24,7 @@ fun createRetrofitInstance(
}
interceptors.forEach { okHttpBuilder.addInterceptor(it) }
if (logEnabled) okHttpBuilder.addInterceptor(createHttpLoggingInterceptor())
if (logEnabled) okHttpBuilder.addInterceptor(createNetworkLoggingInterceptor())
return Retrofit.Builder()
.baseUrl(baseUrl)
@ -31,6 +33,12 @@ fun createRetrofitInstance(
.build()
}
private fun createHttpLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
fun createNetworkLoggingInterceptor(): Interceptor {
return LoggingInterceptor.Builder()
.setLevel(Level.BODY)
.log(Log.VERBOSE)
.tag(NETWORK_LOGS_TAG)
.build()
}
private const val NETWORK_LOGS_TAG = "NetworkLogs"

View file

@ -0,0 +1,59 @@
package com.tangem.datasource.api.common
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.utils.ProviderSuspend
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.IOException
/**
* Switch api environment [Interceptor]
*
* @property id api config id [ApiConfig.ID]
* @property apiConfigsManager api configs manager
*
[REDACTED_AUTHOR]
*/
internal class SwitchEnvironmentInterceptor(
private val id: ApiConfig.ID,
private val apiConfigsManager: ApiConfigsManager,
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
val builder = request.newBuilder()
val environmentConfig = apiConfigsManager.getEnvironmentConfig(id)
request = builder
.url(url = request.url.adjustBaseUrl(environmentConfig.baseUrl))
.addHeaders(headers = environmentConfig.headers)
.build()
return chain.proceed(request)
}
private fun HttpUrl.adjustBaseUrl(url: String): HttpUrl {
return this.newBuilder()
.host(host = url.toHttpUrl().host)
.build()
}
private fun Request.Builder.addHeaders(headers: Map<String, ProviderSuspend<String>>): Request.Builder {
runBlocking {
headers.forEach { (name, valueProvider) ->
val value = valueProvider()
if (value.isNotBlank()) addHeader(name = name, value = value)
}
}
return this
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.api.common
package com.tangem.datasource.api.common.adapter
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
@ -7,7 +7,7 @@ import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
class BigDecimalAdapter {
internal class BigDecimalAdapter {
@FromJson
fun fromJson(value: String) = BigDecimal(value)

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.common.adapter
import com.squareup.moshi.*
import org.joda.time.DateTime
internal class DateTimeAdapter : JsonAdapter<DateTime>() {
@FromJson
override fun fromJson(reader: JsonReader): DateTime? {
return if (reader.peek() == JsonReader.Token.NULL) {
reader.nextNull<DateTime>()
} else {
val dateString = reader.nextString()
DateTime.parse(dateString)
}
}
@ToJson
override fun toJson(writer: JsonWriter, value: DateTime?) {
if (value != null) {
writer.value(value.toString())
} else {
writer.nullValue()
}
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.api.common.adapter
import com.squareup.moshi.*
import org.joda.time.LocalDate
import org.joda.time.format.DateTimeFormat
internal class LocalDateAdapter : JsonAdapter<LocalDate>() {
private val formatter = DateTimeFormat.forPattern("yyyy-MM-dd")
@FromJson
override fun fromJson(reader: JsonReader): LocalDate? {
return if (reader.peek() == JsonReader.Token.NULL) {
reader.nextNull<LocalDate>()
} else {
val dateString = reader.nextString()
return LocalDate.parse(dateString, formatter)
}
}
@ToJson
override fun toJson(writer: JsonWriter, value: LocalDate?) {
if (value != null) {
writer.value(formatter.print(value))
} else {
writer.nullValue()
}
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.datasource.api.common.adapter
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.EnumJsonAdapter
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO.BalanceTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardClaimingDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.RewardTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.transaction.StakingTransactionStatusDTO
import com.tangem.datasource.api.stakekit.models.response.model.transaction.StakingTransactionTypeDTO
/**
* Object to create a adapter for enum types with support for unknown enum values.
*/
object UnknownEnumMoshiAdapter {
@Suppress("UNCHECKED_CAST")
fun <T : Enum<T>> create(enumType: Class<out Enum<*>>, defaultValue: Enum<*>): JsonAdapter<out Enum<*>> {
return EnumJsonAdapter.create(enumType as Class<T>).withUnknownFallback(defaultValue as T)
}
}
fun Moshi.Builder.addStakeKitEnumFallbackAdapters(): Moshi.Builder {
val map = mapOf(
// valid response enums
BalanceTypeDTO::class.java to BalanceTypeDTO.UNKNOWN,
NetworkTypeDTO::class.java to NetworkTypeDTO.UNKNOWN,
RewardClaimingDTO::class.java to RewardClaimingDTO.UNKNOWN,
RewardScheduleDTO::class.java to RewardScheduleDTO.UNKNOWN,
RewardTypeDTO::class.java to RewardTypeDTO.UNKNOWN,
StakingActionStatusDTO::class.java to StakingActionStatusDTO.UNKNOWN,
StakingActionTypeDTO::class.java to StakingActionTypeDTO.UNKNOWN,
StakingTransactionStatusDTO::class.java to StakingTransactionStatusDTO.UNKNOWN,
StakingTransactionTypeDTO::class.java to StakingTransactionTypeDTO.UNKNOWN,
ValidatorStatusDTO::class.java to ValidatorStatusDTO.UNKNOWN,
)
return apply {
map.forEach { entry ->
val enumClass = entry.key
val unknownValue = entry.value
add(enumClass, UnknownEnumMoshiAdapter.create(enumClass, unknownValue))
}
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.datasource.api.common.config
typealias ApiConfigs = Set<@JvmSuppressWildcards ApiConfig>
/**
* Api config
*
* @see <a href="https://www.notion.so/tangem/API-eacb264e7daf420a88b419a8a26f5b26?pvs=4">API configuration</a>
*
[REDACTED_AUTHOR]
*/
sealed class ApiConfig {
/** Default environment */
abstract val defaultEnvironment: ApiEnvironment
/** Available environments */
abstract val environmentConfigs: List<ApiEnvironmentConfig>
/** Unique id */
val id: ID = initializeId()
enum class ID {
Express,
TangemTech,
StakeKit,
TangemVisaAuth,
TangemVisa,
}
private fun initializeId(): ID {
return when (this) {
is Express -> ID.Express
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is TangemVisaAuth -> ID.TangemVisaAuth
is TangemVisa -> ID.TangemVisa
}
}
companion object {
internal const val DEBUG_BUILD_TYPE = "debug"
internal const val DEBUG_PG_BUILD_TYPE = "debugPG"
internal const val INTERNAL_BUILD_TYPE = "internal"
internal const val MOCKED_BUILD_TYPE = "mocked"
internal const val EXTERNAL_BUILD_TYPE = "external"
internal const val RELEASE_BUILD_TYPE = "release"
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.api.common.config
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Api environment
*
[REDACTED_AUTHOR]
*/
@JsonClass(generateAdapter = false)
enum class ApiEnvironment {
@Json(name = "DEV")
DEV,
@Json(name = "STAGE")
STAGE,
@Json(name = "PROD")
PROD,
}

View file

@ -0,0 +1,18 @@
package com.tangem.datasource.api.common.config
import com.tangem.utils.ProviderSuspend
/**
* Api environment config
*
* @property environment environment
* @property baseUrl base url
* @property headers headers
*
[REDACTED_AUTHOR]
*/
data class ApiEnvironmentConfig(
val environment: ApiEnvironment,
val baseUrl: String,
val headers: Map<String, ProviderSuspend<String>> = emptyMap(),
)

View file

@ -0,0 +1,84 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.utils.RequestHeader
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
/**
* Express [ApiConfig]
*
* @property environmentConfigStorage environment config storage
* @property expressAuthProvider express auth provider
* @property appVersionProvider app version provider
*/
internal class Express(
private val environmentConfigStorage: EnvironmentConfigStorage,
private val expressAuthProvider: ExpressAuthProvider,
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createStageEnvironment(),
createProdEnvironment(),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
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 createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://express.tangem.com/v1/",
headers = createHeaders(isProd = true),
)
private fun createHeaders(isProd: Boolean) = buildMap {
put(key = "api-key", value = ProviderSuspend { getApiKey(isProd) })
put(key = "user-id", value = ProviderSuspend(expressAuthProvider::getUserId))
put(key = "session-id", value = ProviderSuspend(expressAuthProvider::getSessionId))
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider).values)
put(key = "refcode", value = ProviderSuspend(expressAuthProvider::getRefCode))
}
private fun getApiKey(isProd: Boolean): String {
return if (isProd) {
environmentConfigStorage.getConfigSync().express
} else {
environmentConfigStorage.getConfigSync().devExpress
}
?.apiKey
?: error("No express config provided")
}
private companion object {
fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
DEBUG_PG_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

@ -0,0 +1,33 @@
package com.tangem.datasource.api.common.config
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.utils.ProviderSuspend
/**
* StakeKit [ApiConfig]
*
* @property stakeKitAuthProvider StakeKit auth provider
*
[REDACTED_AUTHOR]
*/
internal class StakeKit(
private val stakeKitAuthProvider: StakeKitAuthProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.stakek.it/v1/",
headers = mapOf(
"X-API-KEY" to ProviderSuspend(stakeKitAuthProvider::getApiKey),
"accept" to ProviderSuspend { "application/json" },
),
)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.version.AppVersionProvider
/** TangemTech [ApiConfig] */
internal class TangemTech(
private val appVersionProvider: AppVersionProvider,
private val authProvider: AuthProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
override val environmentConfigs = listOf(
createDevEnvironment(),
createProdEnvironment(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem-tech.com/v1/",
headers = createHeaders(),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://devapi.tangem-tech.com/v1/",
headers = createHeaders(),
)
private fun createHeaders() = buildMap {
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider).values)
putAll(from = RequestHeader.AuthenticationHeader(authProvider).values)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.common.config
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemVisa(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
override val environmentConfigs = listOf(
createProdEnvironment(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://bff.tangem.com/",
headers = createHeaders(),
)
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.common.config
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemVisaAuth(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.STAGE
override val environmentConfigs = listOf(
createStageEnvironment(),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "https://api-s.tangem.org/",
headers = createHeaders(),
)
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.datasource.api.common.config.managers
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
/**
* Api configs manager
*
[REDACTED_AUTHOR]
*/
interface ApiConfigsManager {
/** Initialize resources */
suspend fun initialize() {}
/** Get environment config by [id] */
fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig
}

View file

@ -0,0 +1,70 @@
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.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
/**
* Implementation of [ApiConfigsManager] in DEV environment
*
* @param apiConfigs api configs
* @property appPreferencesStore app preferences store
* @property dispatchers coroutine dispatcher provider
*/
internal class DevApiConfigsManager(
apiConfigs: ApiConfigs,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MutableApiConfigsManager {
override val configs: Flow<Map<ApiConfig, ApiEnvironment>> get() = _apiConfigs
private val _apiConfigs = MutableStateFlow(value = apiConfigs.associateWith { it.defaultEnvironment })
override suspend fun initialize() {
// 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)
.onEach { savedEnvironments ->
_apiConfigs.update { apiConfigs ->
apiConfigs.mapValues {
val (config, currentEnvironment) = it
savedEnvironments[config.id.name] ?: currentEnvironment
}
}
}
.launchIn(CoroutineScope(dispatchers.main))
}
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {
val apiConfigs = _apiConfigs.value
val config = apiConfigs.map { it }.firstOrNull { it.key.id == id }?.key
?: error("Api config with id [$id] not found")
val currentEnvironment = apiConfigs[config]
?: error("Current environment of 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) {
appPreferencesStore.editData { mutablePreferences ->
val updatedMap = mutablePreferences.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
.toMutableMap()
.apply { put(id, environment) }
mutablePreferences.setObjectMap(PreferencesKeys.apiConfigsEnvironmentKey, updatedMap)
}
}
}

View file

@ -0,0 +1,19 @@
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 kotlinx.coroutines.flow.Flow
/**
* Mutable [ApiConfigsManager] for change information about the current api environment
*
[REDACTED_AUTHOR]
*/
interface MutableApiConfigsManager : ApiConfigsManager {
/** Api configs with current [ApiEnvironment] */
val configs: Flow<Map<ApiConfig, ApiEnvironment>>
/** Change api environment [environment] by [id] */
suspend fun changeEnvironment(id: String, environment: ApiEnvironment)
}

View file

@ -0,0 +1,26 @@
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.ApiEnvironmentConfig
/**
* Implementation of [ApiConfigsManager] in PROD environment
*
* @property apiConfigs api configs
*/
internal class ProdApiConfigsManager(
private val apiConfigs: ApiConfigs,
) : ApiConfigsManager {
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {
val config = apiConfigs.firstOrNull { it.id == id }
?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI")
return config.environmentConfigs.firstOrNull { it.environment == config.defaultEnvironment }
?: error(
"Api config with id [$id] doesn't contain environment [${config.defaultEnvironment}]. " +
"Check ApiConfig's environments is included default environment",
)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.datasource.api.common.response
/**
* Represents the possible responses from an API request.
*
* @param T The type of the data that is expected in a successful response.
*/
sealed class ApiResponse<T : Any> {
/**
* Represents a successful response from the API.
*
* @property data The data returned by the API.
*/
data class Success<T : Any>(val data: T) : ApiResponse<T>()
/**
* Represents an error response or failure from the API.
*
* @property cause The cause of the error.
*/
data class Error(val cause: ApiResponseError) : ApiResponse<Nothing>()
}
/**
* Wraps data in a [ApiResponse.Success] instance.
*
* @param data The data to wrap.
* @return A [ApiResponse.Success] instance containing the provided data.
*/
internal fun <T : Any> apiSuccess(data: T): ApiResponse<T> = ApiResponse.Success(data)
/**
* Wraps an [ApiResponseError] in a [ApiResponse.Error] instance.
*
* @param cause The error to wrap.
* @return A [ApiResponse.Error] instance containing the provided error.
*/
@Suppress("UNCHECKED_CAST")
internal fun <T : Any> apiError(cause: ApiResponseError): ApiResponse<T> = ApiResponse.Error(cause) as ApiResponse<T>

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.common.response
import retrofit2.Call
import retrofit2.CallAdapter
import java.lang.reflect.Type
internal class ApiResponseCallAdapter(
private val resultType: Type,
) : CallAdapter<Type, Call<ApiResponse<Type>>> {
override fun responseType(): Type = resultType
override fun adapt(call: Call<Type>): Call<ApiResponse<Type>> {
return ApiResponseCallDelegate(call)
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.api.common.response
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory() {
override fun get(returnType: Type, annotations: Array<out Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
if (getRawType(returnType) != Call::class.java) {
return null
}
val callType = getParameterUpperBound(0, returnType as ParameterizedType)
if (getRawType(callType) != ApiResponse::class.java) {
return null
}
val resultType = getParameterUpperBound(0, callType as ParameterizedType)
return ApiResponseCallAdapter(resultType)
}
companion object {
fun create() = ApiResponseCallAdapterFactory()
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.datasource.api.common.response
import okhttp3.Request
import okio.Timeout
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import timber.log.Timber
internal class ApiResponseCallDelegate<T : Any>(
private val wrappedCall: Call<T>,
) : Call<ApiResponse<T>> {
override fun enqueue(callback: Callback<ApiResponse<T>>) {
wrappedCall.enqueue(ApiResponseCallback(callback))
}
override fun execute(): Response<ApiResponse<T>> = throw NotImplementedError()
override fun clone(): Call<ApiResponse<T>> = ApiResponseCallDelegate(wrappedCall.clone())
override fun request(): Request = wrappedCall.request()
override fun timeout(): Timeout = wrappedCall.timeout()
override fun isExecuted(): Boolean = wrappedCall.isExecuted
override fun isCanceled(): Boolean = wrappedCall.isCanceled
override fun cancel() {
wrappedCall.cancel()
}
private inner class ApiResponseCallback(
private val responseCallback: Callback<ApiResponse<T>>,
) : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val safeResponse = response.toSafeApiResponse()
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}
override fun onFailure(call: Call<T>, t: Throwable) {
val error = try {
t.toApiError()
} catch (e: ApiResponseError) {
Timber.e(e, "error map toApiError")
e
} catch (e: Exception) {
Timber.e(e, "onFailure UnknownException")
ApiResponseError.UnknownException(e)
}
val safeResponse = apiError<T>(error)
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}
}
}

View file

@ -0,0 +1,93 @@
package com.tangem.datasource.api.common.response
/**
* Represents the possible errors that can occur during an API request.
*/
sealed class ApiResponseError : Exception() {
/**
* Represents an HTTP exception, which typically occurs when the server responds
* with a non-2xx HTTP status code.
*
* @property code The HTTP status code.
* @property message A human-readable message describing the error.
*/
data class HttpException(
val code: Code,
override val message: String?,
val errorBody: String?,
) : ApiResponseError() {
// region Error Codes
enum class Code(val code: Int) {
// 4xx Server Errors
BAD_REQUEST(code = 400),
UNAUTHORIZED(code = 401),
PAYMENT_REQUIRED(code = 402),
FORBIDDEN(code = 403),
NOT_FOUND(code = 404),
METHOD_NOT_ALLOWED(code = 405),
NOT_ACCEPTABLE(code = 406),
PROXY_AUTHENTICATION_REQUIRED(code = 407),
REQUEST_TIMEOUT(code = 408),
CONFLICT(code = 409),
GONE(code = 410),
LENGTH_REQUIRED(code = 411),
PRECONDITION_FAILED(code = 412),
PAYLOAD_TOO_LARGE(code = 413),
URI_TOO_LONG(code = 414),
UNSUPPORTED_MEDIA_TYPE(code = 415),
RANGE_NOT_SATISFIABLE(code = 416),
EXPECTATION_FAILED(code = 417),
IM_A_TEAPOT(code = 418), // Not an error, but an April Fools' joke from RFC 2324
UNPROCESSABLE_ENTITY(code = 422),
LOCKED(code = 423),
FAILED_DEPENDENCY(code = 424),
TOO_EARLY(code = 425),
UPGRADE_REQUIRED(code = 426),
PRECONDITION_REQUIRED(code = 428),
TOO_MANY_REQUESTS(code = 429),
REQUEST_HEADER_FIELDS_TOO_LARGE(code = 431),
UNAVAILABLE_FOR_LEGAL_REASONS(code = 451),
// 5xx Server Errors
INTERNAL_SERVER_ERROR(code = 500),
NOT_IMPLEMENTED(code = 501),
BAD_GATEWAY(code = 502),
SERVICE_UNAVAILABLE(code = 503),
GATEWAY_TIMEOUT(code = 504),
HTTP_VERSION_NOT_SUPPORTED(code = 505),
VARIANT_ALSO_NEGOTIATES(code = 506),
INSUFFICIENT_STORAGE(code = 507),
LOOP_DETECTED(code = 508),
NOT_EXTENDED(code = 510),
NETWORK_AUTHENTICATION_REQUIRED(code = 511),
;
override fun toString(): String = "$code - $name"
companion object {
val values = values()
}
}
// endregion Error Codes
}
/** Represents a network error, typically when there's no connectivity. */
@Suppress("UnusedPrivateMember")
data object NetworkException : ApiResponseError() {
private fun readResolve(): Any = NetworkException
}
/** Represents a timeout error, typically when the server takes too long to respond. */
@Suppress("UnusedPrivateMember")
data object TimeoutException : ApiResponseError() {
private fun readResolve(): Any = TimeoutException
}
/**
* Represents an unexpected exception that doesn't fall into one of the other categories.
*
* @property cause The exception that caused this error.
*/
data class UnknownException(override val cause: Throwable) : ApiResponseError()
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.common.response
fun <T : Any> ApiResponse<T>.getOrThrow(): T = when (this) {
is ApiResponse.Error -> throw cause
is ApiResponse.Success -> data
}
fun <T : Any, R : Any> ApiResponse<T>.fold(onSuccess: (T) -> R, onError: (ApiResponseError) -> R): R {
return when (this) {
is ApiResponse.Error -> onError(cause)
is ApiResponse.Success -> onSuccess(data)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.datasource.api.common.response
import kotlinx.coroutines.TimeoutCancellationException
import retrofit2.Response
import timber.log.Timber
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.TimeoutException
import javax.net.ssl.SSLHandshakeException
internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
val body = body()
return if (isSuccessful && body != null) {
apiSuccess(body)
} else {
val code = ApiResponseError.HttpException.Code.values
.firstOrNull { it.code == code() }
val e = try {
if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))
} else {
ApiResponseError.HttpException(code, message(), errorBody()?.string())
}
} catch (e: Exception) {
Timber.e(e, "UnknownException occured")
ApiResponseError.UnknownException(e)
}
apiError(e)
}
}
internal fun Throwable.toApiError(): ApiResponseError = when (this) {
is ConnectException,
is UnknownHostException,
is SSLHandshakeException,
-> ApiResponseError.NetworkException
is TimeoutException,
is TimeoutCancellationException,
is SocketTimeoutException,
-> ApiResponseError.TimeoutException
else -> ApiResponseError.UnknownException(cause = this)
}

View file

@ -0,0 +1,6 @@
package com.tangem.datasource.api.common.visa
interface TangemVisaAuthProvider {
suspend fun getAuthHeader(cardId: String): String
}

View file

@ -0,0 +1,64 @@
package com.tangem.datasource.api.express
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.express.models.request.AssetsRequestBody
import com.tangem.datasource.api.express.models.request.ExchangeSentRequestBody
import com.tangem.datasource.api.express.models.request.PairsRequestBody
import com.tangem.datasource.api.express.models.response.*
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
/**
* Interface of Tangem Express API (new swap mechanism)
*/
@Suppress("LongParameterList")
interface TangemExpressApi {
@POST("assets")
suspend fun getAssets(@Body body: AssetsRequestBody): ApiResponse<List<Asset>>
@POST("pairs")
suspend fun getPairs(@Body body: PairsRequestBody): ApiResponse<List<SwapPair>>
@GET("providers")
suspend fun getProviders(): ApiResponse<List<ExchangeProvider>>
@GET("exchange-quote")
suspend fun getExchangeQuote(
@Query("fromContractAddress") fromContractAddress: String,
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: String,
@Query("fromDecimals") fromDecimals: Int,
@Query("toDecimals") toDecimals: Int,
@Query("providerId") providerId: String,
@Query("rateType") rateType: String,
): ApiResponse<ExchangeQuoteResponse>
@GET("exchange-data")
suspend fun getExchangeData(
@Query("fromContractAddress") fromContractAddress: String,
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("fromAddress") fromAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: String,
@Query("fromDecimals") fromDecimals: Int,
@Query("toDecimals") toDecimals: Int,
@Query("providerId") providerId: String,
@Query("rateType") rateType: String,
@Query("toAddress") toAddress: String,
@Query("requestId") requestId: String,
@Query("refundAddress") refundAddress: String?, // for cex only
@Query("refundExtraId") refundExtraId: String?, // for cex only
): ApiResponse<ExchangeDataResponse>
@GET("exchange-status")
suspend fun getExchangeStatus(@Query("txId") txId: String): ApiResponse<ExchangeStatusResponse>
@POST("exchange-sent")
suspend fun exchangeSent(@Body body: ExchangeSentRequestBody): ApiResponse<ExchangeSentResponseBody>
}

View file

@ -0,0 +1,5 @@
package com.tangem.datasource.api.express.models
object TangemExpressValues {
const val EMPTY_CONTRACT_ADDRESS_VALUE = "0"
}

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.express.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AssetsRequestBody(
@Json(name = "tokensList") val tokensList: List<LeastTokenInfo>?,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.express.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeSentRequestBody(
@Json(name = "txId")
val txId: String,
@Json(name = "fromNetwork")
val fromNetwork: String,
@Json(name = "fromAddress")
val fromAddress: String,
@Json(name = "payinAddress")
val payinAddress: String,
@Json(name = "payinExtraId")
val payinExtraId: String?,
@Json(name = "txHash")
val txHash: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.express.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LeastTokenInfo(
@Json(name = "contractAddress")
val contractAddress: String,
@Json(name = "network")
val network: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.express.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PairsRequestBody(
@Json(name = "from")
val from: List<LeastTokenInfo>,
@Json(name = "to")
val to: List<LeastTokenInfo>,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Asset(
@Json(name = "contractAddress")
val contractAddress: String,
@Json(name = "network")
val network: String,
@Json(name = "exchangeAvailable")
val exchangeAvailable: Boolean,
@Json(name = "onrampAvailable")
val onrampAvailable: Boolean?,
)

View file

@ -0,0 +1,87 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeDataResponseWithTxDetails(
@Json(name = "dataResponse")
val dataResponse: ExchangeDataResponse,
@Json(name = "txDetails")
val txDetails: TxDetails,
)
@JsonClass(generateAdapter = true)
data class ExchangeDataResponse(
@Json(name = "fromAmount")
val fromAmount: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "toAmount")
val toAmount: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "txId")
val txId: String, // inner tangem-express transaction id
@Json(name = "txDetailsJson")
val txDetailsJson: String,
@Json(name = "signature")
val signature: String,
)
@JsonClass(generateAdapter = true)
data class TxDetails(
@Json(name = "payoutAddress")
val payoutAddress: String,
@Json(name = "requestId")
val requestId: String,
@Json(name = "txType")
val txType: TxType,
@Json(name = "txFrom")
val txFrom: String?, // account for debiting tokens (same as toAddress) if DEX, null if CEX
@Json(name = "txTo")
val txTo: String, // swap smart-contract address if DEX, address for sending transaction if CEX
@Json(name = "txData")
val txData: String?, // transaction data if DEX, null if CEX
@Json(name = "txValue")
val txValue: String, // amount (same as fromAmount for Coin, but for bridge equal to otherNativeFee)
@Json(name = "otherNativeFee")
val otherNativeFee: String?,
@Json(name = "externalTxId")
val externalTxId: String?, // null if DEX, provider transaction id if CEX
@Json(name = "externalTxUrl")
val externalTxUrl: String?, // null if DEX, url of provider exchange status page if CEX
@Json(name = "txExtraIdName")
val txExtraIdName: String?,
@Json(name = "txExtraId")
val txExtraId: String?,
@Json(name = "gas")
val gas: String?,
)
@JsonClass(generateAdapter = false)
enum class TxType {
@Json(name = "send")
SEND,
@Json(name = "swap")
SWAP,
}

View file

@ -0,0 +1,50 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class ExchangeProvider(
@Json(name = "id")
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "type")
val type: ExchangeProviderType,
@Json(name = "imageLarge")
val imageLargeUrl: String,
@Json(name = "imageSmall")
val imageSmallUrl: String,
@Json(name = "termsOfUse")
val termsOfUse: String?,
@Json(name = "privacyPolicy")
val privacyPolicy: String?,
@Json(name = "recommended")
val isRecommended: Boolean = false,
@Json(name = "slippage")
val slippage: BigDecimal?,
)
@JsonClass(generateAdapter = false)
enum class ExchangeProviderType {
@Json(name = "dex")
DEX,
@Json(name = "cex")
CEX,
@Json(name = "dex-bridge")
DEX_BRIDGE,
@Json(name = "onramp")
ONRAMP,
}

View file

@ -0,0 +1,28 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class ExchangeQuoteResponse(
@Json(name = "fromAmount")
val fromAmount: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "toAmount")
val toAmount: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "allowanceContract")
val allowanceContract: String?,
@Json(name = "minAmount")
val minAmount: BigDecimal,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeSentResponseBody(
@Json(name = "txId")
val txId: String,
@Json(name = "status")
val status: String,
)

View file

@ -0,0 +1,84 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeStatusResponse(
@Json(name = "providerId")
val providerId: String,
@Json(name = "status")
val status: ExchangeStatus,
@Json(name = "externalTxId")
val externalTxId: String?,
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
@Json(name = "error")
val error: ExchangeStatusError?,
@Json(name = "refundNetwork")
val refundNetwork: String? = null,
@Json(name = "refundContractAddress")
val refundContractAddress: String? = null,
)
@JsonClass(generateAdapter = false)
enum class ExchangeStatus {
@Json(name = "new")
New,
@Json(name = "waiting")
Waiting,
@Json(name = "confirming")
Confirming,
@Json(name = "exchanging")
Exchanging,
@Json(name = "sending")
Sending,
@Json(name = "finished")
Finished,
@Json(name = "failed")
Failed,
@Json(name = "refunded")
Refunded,
@Json(name = "verifying")
Verifying,
@Json(name = "expired")
Cancelled,
@Json(name = "waiting-tx-hash")
WaitingTxHash,
@Json(name = "tx-failed")
TxFailed,
@Json(name = "paused")
Paused,
@Json(name = "unknown")
Unknown,
}
@JsonClass(generateAdapter = true)
data class ExchangeStatusError(
@Json(name = "code")
val code: Int,
@Json(name = "description")
val description: String,
)

View file

@ -0,0 +1,50 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class ExpressErrorResponse(
@Json(name = "error")
val error: ExpressError,
)
@JsonClass(generateAdapter = true)
data class ExpressError(
@Json(name = "code")
val code: Int,
@Json(name = "description")
val description: String?,
@Json(name = "value")
val value: ExpressErrorValue?,
)
@JsonClass(generateAdapter = true)
data class ExpressErrorValue(
@Json(name = "minAmount")
val minAmount: String?,
@Json(name = "maxAmount")
val maxAmount: String?,
@Json(name = "decimals")
val decimals: Int?,
@Json(name = "currentAllowance")
val currentAllowance: BigDecimal?,
@Json(name = "receivedFromDecimals")
val receivedFromDecimals: Int?,
@Json(name = "expressFromDecimals")
val expressFromDecimals: Int?,
@Json(name = "fromAmount")
val fromAmount: String?,
@Json(name = "fromAmountProvider")
val fromAmountProvider: String?,
)

View file

@ -0,0 +1,36 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
@JsonClass(generateAdapter = true)
data class SwapPair(
@Json(name = "from")
val from: LeastTokenInfo,
@Json(name = "to")
val to: LeastTokenInfo,
@Json(name = "providers")
val providers: List<SwapPairProvider>,
)
@JsonClass(generateAdapter = true)
data class SwapPairProvider(
@Json(name = "providerId")
val providerId: String,
@Json(name = "rateTypes")
val rateTypes: List<RateType>,
)
@JsonClass(generateAdapter = false)
enum class RateType {
@Json(name = "float")
FLOAT,
@Json(name = "fixed")
FIXED,
}

View file

@ -0,0 +1,6 @@
package com.tangem.datasource.api.express.models.response
class SwapPairsWithProviders(
val swapPair: List<SwapPair>,
val providers: List<ExchangeProvider>,
)

View file

@ -0,0 +1,46 @@
package com.tangem.datasource.api.markets
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.markets.models.response.*
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface TangemTechMarketsApi {
@Suppress("LongParameterList")
@GET("coins/list")
suspend fun getCoinsList(
@Query("currency") currency: String,
@Query("interval") interval: String,
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("order") order: String,
@Query("search") search: String?,
@Query("timestamp") timestamp: Long?,
): ApiResponse<TokenMarketListResponse>
@GET("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")
suspend fun getCoinChart(
@Path("coin_id") coinId: String,
@Query("currency") currency: String,
@Query("interval") interval: String,
): ApiResponse<TokenMarketChartResponse>
@GET("coins/{coin_id}/exchanges")
suspend fun getCoinExchanges(@Path("coin_id") coinId: String): ApiResponse<TokenMarketExchangesResponse>
@GET("coins/history_preview")
suspend fun getCoinsListCharts(
@Query("coin_ids") coinIds: String,
@Query("currency") currency: String,
@Query("interval") interval: String,
): ApiResponse<TokenMarketChartListResponse>
}

View file

@ -0,0 +1,3 @@
package com.tangem.datasource.api.markets.models.response
typealias TokenMarketChartListResponse = Map<String, TokenMarketChartResponse>

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.markets.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class TokenMarketChartResponse(
// There is a bug in the API, it returns null values.
// We need to filter them out.
@Json(name = "prices")
val prices: Map<Long, BigDecimal?>,
)

View file

@ -0,0 +1,38 @@
package com.tangem.datasource.api.markets.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Token market exchanges response
*
* @property exchanges list of exchanges
*
[REDACTED_AUTHOR]
*/
@JsonClass(generateAdapter = true)
data class TokenMarketExchangesResponse(
@Json(name = "exchanges") val exchanges: List<Exchange>,
) {
/**
* Exchange
*
* @property id id
* @property name name
* @property imageUrl image url
* @property isCentralized CEX (true), DEX (false)
* @property volumeInUsd aggregated volume in USD
* @property trustScore trust score
*/
@JsonClass(generateAdapter = true)
data class Exchange(
@Json(name = "exchange_id") val id: String,
@Json(name = "name") val name: String,
@Json(name = "image") val imageUrl: String?,
@Json(name = "centralized") val isCentralized: Boolean,
@Json(name = "volume_usd") val volumeInUsd: BigDecimal,
@Json(name = "trust_score") val trustScore: Int?,
)
}

View file

@ -0,0 +1,179 @@
package com.tangem.datasource.api.markets.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class TokenMarketInfoResponse(
@Json(name = "id")
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "symbol")
val symbol: String,
@Json(name = "current_price")
val currentPrice: BigDecimal,
@Json(name = "price_change_percentage")
val priceChangePercentage: PriceChangePercentage?,
@Json(name = "networks")
val networks: List<Network>?,
@Json(name = "short_description")
val shortDescription: String?,
@Json(name = "full_description")
val fullDescription: String?,
@Json(name = "insights")
val insights: Insights?,
@Json(name = "metrics")
val metrics: Metrics?,
@Json(name = "security_data")
val securityData: SecurityData?,
@Json(name = "links")
val links: Links?,
@Json(name = "price_performance")
val pricePerformance: PricePerformance?,
@Json(name = "exchanges_amount")
val exchangesAmount: Int?,
) {
@JsonClass(generateAdapter = true)
data class PriceChangePercentage(
@Json(name = "24h")
val day: BigDecimal?,
@Json(name = "1w")
val week: BigDecimal?,
@Json(name = "1m")
val month: BigDecimal?,
@Json(name = "3m")
val threeMonths: BigDecimal?,
@Json(name = "6m")
val sixMonths: BigDecimal?,
@Json(name = "1y")
val year: BigDecimal?,
@Json(name = "all_time")
val allTime: BigDecimal?,
)
@JsonClass(generateAdapter = true)
data class Network(
@Json(name = "network_id")
val networkId: String,
@Json(name = "exchangeable")
val exchangeable: Boolean = false,
@Json(name = "contract_address")
val contractAddress: String?,
@Json(name = "decimal_count")
val decimalCount: Int?,
)
@JsonClass(generateAdapter = true)
data class Insights(
@Json(name = "holders_change")
val holdersChange: Change?,
@Json(name = "liquidity_change")
val liquidityChange: Change?,
@Json(name = "buy_pressure_change")
val buyPressureChange: Change?,
@Json(name = "experienced_buyer_change")
val experiencedBuyerChange: Change?,
@Json(name = "networks")
val sourceNetworks: List<SourceNetwork>?,
) {
@JsonClass(generateAdapter = true)
data class SourceNetwork(
@Json(name = "network_id")
val id: String,
@Json(name = "network_name")
val name: String,
)
}
@JsonClass(generateAdapter = true)
data class Change(
@Json(name = "24h")
val day: BigDecimal?,
@Json(name = "1w")
val week: BigDecimal?,
@Json(name = "1m")
val month: BigDecimal?,
)
@JsonClass(generateAdapter = true)
data class Metrics(
@Json(name = "market_rating")
val marketRating: Int?,
@Json(name = "circulating_supply")
val circulatingSupply: BigDecimal?,
@Json(name = "market_cap")
val marketCap: BigDecimal?,
@Json(name = "volume_24h")
val volume24h: BigDecimal?,
@Json(name = "max_supply")
val maxSupply: BigDecimal?,
@Json(name = "fully_diluted_valuation")
val fullyDilutedValuation: BigDecimal?,
)
@JsonClass(generateAdapter = true)
data class Links(
@Json(name = "official_links")
val officialLinks: List<Link>? = null,
@Json(name = "social")
val social: List<Link>? = null,
@Json(name = "repository")
val repository: List<Link>? = null,
@Json(name = "blockchain_site")
val blockchainSite: List<Link>? = null,
)
@JsonClass(generateAdapter = true)
data class Link(
@Json(name = "title")
val title: String,
@Json(name = "id")
val id: String?,
@Json(name = "link")
val link: String,
)
@JsonClass(generateAdapter = true)
data class PricePerformance(
@Json(name = "24h")
val day: Range?,
@Json(name = "1m")
val month: Range?,
@Json(name = "all_time")
val allTime: Range?,
)
@JsonClass(generateAdapter = true)
data class SecurityData(
@Json(name = "total_security_score")
val totalSecurityScore: Float,
@Json(name = "provider_data")
val providerData: List<ProviderData>,
)
@JsonClass(generateAdapter = true)
data class ProviderData(
@Json(name = "provider_id")
val providerId: String,
@Json(name = "provider_name")
val providerName: String,
@Json(name = "link")
val link: String?,
@Json(name = "security_score")
val securityScore: Float,
@Json(name = "last_audit_date")
val lastAuditDate: DateTime?,
)
@JsonClass(generateAdapter = true)
data class Range(
@Json(name = "low_price")
val low: BigDecimal?,
@Json(name = "high_price")
val high: BigDecimal?,
)
}

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