Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-18 17:08:11 +03:00
commit dbb63a396f
448 changed files with 12230 additions and 2075 deletions

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.api.common.blockaid
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse
import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse
import retrofit2.http.Body
import retrofit2.http.POST
interface BlockAidApi {
@POST("site/scan")
suspend fun scanDomain(@Body request: DomainScanRequest): DomainScanResponse
@POST("evm/json-rpc/scan")
suspend fun scanJsonRpc(@Body request: EvmTransactionScanRequest): TransactionScanResponse
@POST("solana/message/scan")
suspend fun scanSolanaMessage(@Body request: SolanaTransactionScanRequest): TransactionScanResponse
}

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.common.blockaid.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DomainScanRequest(
@Json(name = "url") val url: String,
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.common.blockaid.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata
@JsonClass(generateAdapter = true)
data class EvmTransactionScanRequest(
@Json(name = "chain") val chain: String,
@Json(name = "account_address") val accountAddress: String,
@Json(name = "method") val method: String,
@Json(name = "data") val data: RpcData,
@Json(name = "options") val options: List<String> = listOf("simulation", "validation"),
@Json(name = "metadata") val metadata: TransactionMetadata,
)
@JsonClass(generateAdapter = true)
data class RpcData(
@Json(name = "jsonrpc") val jsonrpc: String = "2.0",
@Json(name = "method") val method: String,
@Json(name = "params") val params: String,
)

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.common.blockaid.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata
@JsonClass(generateAdapter = true)
data class SolanaTransactionScanRequest(
@Json(name = "encoding") val encoding: String = "base64",
@Json(name = "chain") val chain: String,
@Json(name = "method") val method: String,
@Json(name = "options") val options: List<String> = listOf("simulation, validation"),
@Json(name = "metadata") val metadata: TransactionMetadata,
@Json(name = "account_address") val accountAddress: String,
@Json(name = "transactions") val transactions: List<String>,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AccountSummaryResponse(
@Json(name = "assets_diffs") val assetsDiffs: List<AssetDiff>,
@Json(name = "exposures") val exposures: List<Exposure>,
)

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AssetDiff(
@Json(name = "asset_type") val assetType: String,
@Json(name = "asset") val asset: Asset,
@Json(name = "in") val inTransfer: List<Transfer>? = null,
@Json(name = "out") val outTransfer: List<Transfer>? = null,
)
@JsonClass(generateAdapter = true)
data class Asset(
@Json(name = "chain_id") val chainId: Int? = null,
@Json(name = "logo_url") val logoUrl: String? = null,
@Json(name = "symbol") val symbol: String,
)
@JsonClass(generateAdapter = true)
data class Transfer(
@Json(name = "value") val value: String,
@Json(name = "raw_value") val rawValue: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DomainScanResponse(
@Json(name = "status") val status: String,
@Json(name = "is_malicious") val isMalicious: Boolean?,
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Exposure(
@Json(name = "asset") val asset: Asset,
@Json(name = "spenders") val spenders: Map<String, SpenderDetails>,
)
@JsonClass(generateAdapter = true)
data class SpenderDetails(
@Json(name = "exposure") val exposure: List<ExposureDetail>,
@Json(name = "is_approved_for_all") val isApprovedForAll: Boolean? = null,
)
@JsonClass(generateAdapter = true)
data class ExposureDetail(
@Json(name = "value") val value: String,
@Json(name = "raw_value") val rawValue: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SimulationResponse(
@Json(name = "status") val status: String,
@Json(name = "account_summary") val accountSummary: AccountSummaryResponse,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TransactionMetadata(
@Json(name = "domain") val domain: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TransactionScanResponse(
@Json(name = "validation") val validation: ValidationResponse,
@Json(name = "simulation") val simulation: SimulationResponse,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ValidationResponse(
@Json(name = "status") val status: String,
@Json(name = "result_type") val resultType: String,
)

View file

@ -27,6 +27,7 @@ sealed class ApiConfig {
TangemVisaAuth,
TangemVisa,
TangemCardSdk,
BlockAid,
}
private fun initializeId(): ID {
@ -37,6 +38,7 @@ sealed class ApiConfig {
is TangemVisaAuth -> ID.TangemVisaAuth
is TangemVisa -> ID.TangemVisa
is TangemCardSdk -> ID.TangemCardSdk
is BlockAid -> ID.BlockAid
}
}
@ -44,6 +46,7 @@ sealed class ApiConfig {
internal const val DEBUG_BUILD_TYPE = "debug"
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,27 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.utils.ProviderSuspend
internal class BlockAid(
private val environmentConfigStorage: EnvironmentConfigStorage,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
override val environmentConfigs = listOf(
createProdEnvironment(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.blockaid.io/v0/",
headers = buildMap {
environmentConfigStorage.getConfigSync().blockAidApiKey?.let { apiKey ->
put("X-API-KEY", ProviderSuspend { apiKey })
}
put("accept", ProviderSuspend { "application/json" })
put("content-type", ProviderSuspend { "application/json" })
},
)
}

View file

@ -73,6 +73,7 @@ internal class Express(
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

@ -140,6 +140,40 @@ interface TangemTechApi {
@GET("stories/{story_id}")
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
// region push notifications
@GET("notification/push_notifications_eligible_networks")
suspend fun getEligibleNetworksForPushNotifications(): ApiResponse<List<CryptoNetworkResponse>>
@POST("user-wallets/applications/")
suspend fun createApplicationId(
@Body
body: NotificationApplicationCreateBody,
): ApiResponse<NotificationApplicationIdResponse>
@PATCH("user-wallets/applications/{application_id}")
suspend fun updatePushTokenForApplicationId(
@Path("application_id") applicationId: String,
@Body body: NotificationApplicationCreateBody,
): ApiResponse<String>
@PATCH("user-wallets/wallets/{wallet_id}/notify")
suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>
// endregion
// region wallets
@PATCH("user-wallets/wallets/{wallet_id}")
suspend fun updateWallet(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>
@POST("user-wallets/wallets/create-and-connect-by-appuid/{application_id}")
suspend fun associateApplicationIdWithWallets(
@Path("application_id") applicationId: String,
@Body body: List<WalletIdBody>,
): ApiResponse<Unit>
@GET("user-wallets/wallets/{wallet_id}")
suspend fun getWalletById(@Path("wallet_id") walletId: String): ApiResponse<WalletResponse>
// endregion
companion object {
val marketsQuoteFields = listOf(
"price",

View file

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

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NotificationApplicationCreateBody(
@Json(name = "pushToken") val pushToken: String,
@Json(name = "platform") val platform: String,
@Json(name = "device") val device: String,
@Json(name = "systemVersion") val systemVersion: String,
@Json(name = "language") val language: String,
@Json(name = "timezone") val timezone: String,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NotificationApplicationIdResponse(
@Json(name = "uid") val appId: String,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NotificationApplicationUpdateBody(
@Json(name = "pushToken") val pushToken: String,
@Json(name = "systemVersion") val systemVersion: String? = null,
@Json(name = "language") val language: String? = null,
@Json(name = "timezone") val timezone: String? = null,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WalletBody(
@Json(name = "notifyStatus") val notifyStatus: String? = null,
@Json(name = "name") val name: String? = null,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WalletIdBody(
@Json(name = "id") val walletId: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WalletResponse(
@Json(name = "notifyStatus") val notifyStatus: String? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "id") val id: String,
)

View file

@ -7,10 +7,13 @@ import com.squareup.moshi.JsonClass
data class CardActivationRemoteStateResponse(
@Json(name = "activation_status") val status: String,
@Json(name = "activation_order") val activationOrder: ActivationOrder?,
@Json(name = "stepChangeCode") val stepChangeCode: Int?,
@Json(name = "updatedAt") val updatedAt: String?,
) {
@JsonClass(generateAdapter = true)
data class ActivationOrder(
@Json(name = "id") val id: String,
@Json(name = "customer_id") val customerId: String,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.datasource.appcurrency
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import kotlinx.coroutines.flow.Flow
/**
* Store of app currency data model [CurrenciesResponse.Currency]
*
[REDACTED_AUTHOR]
*/
interface AppCurrencyResponseStore {
/** Get flow of [CurrenciesResponse.Currency] */
fun get(): Flow<CurrenciesResponse.Currency?>
/** Get [CurrenciesResponse.Currency] synchronously or null */
suspend fun getSyncOrNull(): CurrenciesResponse.Currency?
}

View file

@ -0,0 +1,28 @@
package com.tangem.datasource.appcurrency
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import kotlinx.coroutines.flow.Flow
/**
* Default implementation of [AppCurrencyResponseStore]
*
* @property appPreferencesStore app preferences store
*/
internal class DefaultAppCurrencyResponseStore(
private val appPreferencesStore: AppPreferencesStore,
) : AppCurrencyResponseStore {
override fun get(): Flow<CurrenciesResponse.Currency?> {
return appPreferencesStore.getObject(PreferencesKeys.SELECTED_APP_CURRENCY_KEY)
}
override suspend fun getSyncOrNull(): CurrenciesResponse.Currency? {
return appPreferencesStore.getObjectSyncOrNull<CurrenciesResponse.Currency>(
PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
)
}
}

View file

@ -3,8 +3,10 @@ package com.tangem.datasource.asset.loader
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.adapter
import com.tangem.utils.coroutines.runCatching
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -23,69 +25,63 @@ import javax.inject.Singleton
class AssetLoader @Inject constructor(
val assetReader: AssetReader,
@NetworkMoshi val moshi: Moshi,
val dispatchers: CoroutineDispatcherProvider,
) {
/** Load content [Content] of asset file [fileName] */
@OptIn(ExperimentalStdlibApi::class)
suspend inline fun <reified Content> load(fileName: String): Content? {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified Content> load(fileName: String): Content? = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
moshi.adapter<Content>().fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
null
},
)
moshi.adapter<Content>().fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
null
},
)
/** Load list [V] values of asset file [fileName] */
suspend inline fun <reified V> loadList(fileName: String): List<V> {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified V> loadList(fileName: String): List<V> = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
val type = Types.newParameterizedType(List::class.java, V::class.java)
val adapter = moshi.adapter<List<V>>(type)
val type = Types.newParameterizedType(List::class.java, V::class.java)
val adapter = moshi.adapter<List<V>>(type)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyList()
},
)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyList()
},
)
/** Load map [String] keys and [V] values of asset file [fileName] */
suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyMap()
},
)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyMap()
},
)
}

View file

@ -1,23 +1,19 @@
package com.tangem.datasource.asset.reader
import android.content.res.AssetManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.io.BufferedReader
/**
* Implementation of asset file reader
*
* @property assetManager asset manager
* @property dispatchers dispatchers
*/
internal class AndroidAssetReader(
private val assetManager: AssetManager,
private val dispatchers: CoroutineDispatcherProvider,
) : AssetReader {
override suspend fun read(fullFileName: String): String = withContext(dispatchers.io) {
assetManager.open(fullFileName).bufferedReader()
override suspend fun read(fullFileName: String): String {
return assetManager.open(fullFileName).bufferedReader()
.use(BufferedReader::readText)
}
}

View file

@ -46,6 +46,12 @@ internal object ApiConfigsModule {
@IntoSet
fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemVisa(appVersionProvider)
@Provides
@IntoSet
fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig {
return BlockAid(environmentConfigStorage)
}
@Provides
@IntoSet
fun provideTangemCardSdkConfig(): ApiConfig = TangemCardSdk()

View file

@ -1,8 +1,11 @@
package com.tangem.datasource.di
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.datasource.appcurrency.DefaultAppCurrencyResponseStore
import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore
import com.tangem.datasource.local.appcurrency.implementation.DefaultAvailableAppCurrenciesStore
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -18,4 +21,10 @@ internal object AppCurrencyDataModule {
fun provideAvailableAppCurrenciesStore(): AvailableAppCurrenciesStore {
return DefaultAvailableAppCurrenciesStore(dataStore = RuntimeDataStore())
}
@Provides
@Singleton
fun provideAppCurrencyResponseStore(appPreferencesStore: AppPreferencesStore): AppCurrencyResponseStore {
return DefaultAppCurrencyResponseStore(appPreferencesStore)
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.datasource.di
import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.datasource.local.*
import com.tangem.datasource.local.preferences.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -26,6 +25,7 @@ internal object AppPreferencesStoreModule {
return AppPreferencesStore(
preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io),
moshi = moshi,
dispatchers = dispatchers,
)
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.datasource.di
import android.content.Context
import com.tangem.datasource.asset.reader.AndroidAssetReader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -17,10 +16,7 @@ internal object AssetReaderModule {
@Singleton
@Provides
fun providesAsserReader(
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): AssetReader {
return AndroidAssetReader(context.assets, dispatchers)
fun providesAsserReader(@ApplicationContext context: Context): AssetReader {
return AndroidAssetReader(context.assets)
}
}

View file

@ -51,12 +51,14 @@ class MoshiModule {
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")
.withSubtype(NFTCollection.Identifier.TON::class.java, "ton")
.withSubtype(NFTCollection.Identifier.Solana::class.java, "sol")
.withDefaultValue(NFTCollection.Identifier.Unknown),
)
.add(
PolymorphicJsonAdapterFactory.of(NFTAsset.Identifier::class.java, "bc")
.withSubtype(NFTAsset.Identifier.EVM::class.java, "evm")
.withSubtype(NFTAsset.Identifier.TON::class.java, "ton")
.withSubtype(NFTAsset.Identifier.Solana::class.java, "sol")
.withDefaultValue(NFTAsset.Identifier.Unknown),
)
.addLast(KotlinJsonAdapterFactory())

View file

@ -4,6 +4,7 @@ import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
@ -282,6 +283,29 @@ internal object NetworkModule {
.create(T::class.java)
}
@Provides
@Singleton
fun provideBlockAidApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): BlockAidApi {
return createApi<BlockAidApi>(
id = ApiConfig.ID.BlockAid,
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
).applyTimeoutAnnotations()
},
)
}
private inline fun <reified T> createApi(
id: ApiConfig.ID,
moshi: Moshi,

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
@ -26,21 +27,27 @@ internal object QuotesStoreModule {
@Provides
@Singleton
fun provideQuotesStore(
fun providePersistenceQuotesStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): QuotesStore {
return DefaultQuotesStore(
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<QuotesResponse.Quote>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
): DataStore<Map<String, QuotesResponse.Quote>> {
return DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<QuotesResponse.Quote>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
@Provides
@Singleton
fun provideQuotesStore(persistenceStore: DataStore<Map<String, QuotesResponse.Quote>>): QuotesStore {
return DefaultQuotesStore(
persistenceStore = persistenceStore,
runtimeStore = RuntimeSharedStore(),
)
}

View file

@ -14,4 +14,5 @@ data class EnvironmentConfig(
val express: ExpressModel? = null,
val devExpress: ExpressModel? = null,
val stakeKitApiKey: String? = null,
val blockAidApiKey: String? = null,
)

View file

@ -23,6 +23,7 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
express = value.express,
devExpress = value.devExpress,
stakeKitApiKey = value.stakeKitApiKey,
blockAidApiKey = value.blockaidApiKey,
)
}
}

View file

@ -38,6 +38,7 @@ class EnvironmentConfigModel(
@Json(name = "alephiumTangemApiKey") val alephiumTangemApiKey: String?,
@Json(name = "moralisApiKey") val moralisApiKey: String?,
@Json(name = "nftScanApiKey") val nftScanApiKey: String?,
@Json(name = "blockaidApiKey") val blockaidApiKey: String?,
)
@JsonClass(generateAdapter = true)

View file

@ -37,8 +37,12 @@ class AppLogsStore @Inject constructor(
private val mutex = Mutex()
private val zipMutex = Mutex()
private val file = File(applicationContext.filesDir, PERMITTED_FILE_NAME)
private val fileZip = File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP)
private val logFile by lazy {
File(applicationContext.filesDir, PERMITTED_FILE_NAME)
}
private val logFileZip by lazy {
File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP)
}
private val formatter = DateTimeFormatterBuilder()
.appendDayOfMonth(2)
@ -55,12 +59,12 @@ class AppLogsStore @Inject constructor(
.toFormatter()
/** Get log file */
fun getFile(): File? = if (file.exists()) file else null
fun getFile(): File? = if (logFile.exists()) logFile else null
suspend fun getZipFile(): File? {
return zipMutex.withLock {
if (file.exists()) {
zip(listOf(file), fileZip)
if (logFile.exists()) {
zip(listOf(logFile), logFileZip)
} else {
null
}
@ -98,8 +102,8 @@ class AppLogsStore @Inject constructor(
/** Delete deprecated logs if file size exceeds [maxSize] */
fun deleteDeprecatedLogs(maxSize: Int) {
launchWithLock {
if (file.exists() && file.length() > maxSize) {
file.delete()
if (logFile.exists() && logFile.length() > maxSize) {
logFile.delete()
}
}
}
@ -117,7 +121,7 @@ class AppLogsStore @Inject constructor(
}
private fun writeMessage(tag: String, vararg messages: String) {
BufferedWriter(FileWriter(file, true)).use { writer ->
BufferedWriter(FileWriter(logFile, true)).use { writer ->
writer.append(formatter.print(DateTime.now()))
writer.append(": $tag ")
messages.forEach(writer::append)
@ -126,8 +130,8 @@ class AppLogsStore @Inject constructor(
}
private fun createFileIfNotExist() {
if (!file.exists()) {
runCatching { file.createNewFile() }
if (!logFile.exists()) {
runCatching { logFile.createNewFile() }
.onFailure(Timber::e)
}
}

View file

@ -13,6 +13,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter<SdkNFTAsset.Identifier,
is SdkNFTAsset.Identifier.TON -> NFTAsset.Identifier.TON(
tokenAddress = value.tokenAddress,
)
is SdkNFTAsset.Identifier.Solana -> NFTAsset.Identifier.Solana(
tokenAddress = value.tokenAddress,
cnft = value.cnft,
)
is SdkNFTAsset.Identifier.Unknown -> NFTAsset.Identifier.Unknown
}
@ -24,6 +28,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter<SdkNFTAsset.Identifier,
is NFTAsset.Identifier.TON -> SdkNFTAsset.Identifier.TON(
tokenAddress = value.tokenAddress,
)
is NFTAsset.Identifier.Solana -> SdkNFTAsset.Identifier.Solana(
tokenAddress = value.tokenAddress,
cnft = value.cnft,
)
is NFTAsset.Identifier.Unknown -> SdkNFTAsset.Identifier.Unknown
}
}

View file

@ -12,6 +12,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter<SdkNFTCollection.Id
is SdkNFTCollection.Identifier.TON -> NFTCollection.Identifier.TON(
contractAddress = value.contractAddress,
)
is SdkNFTCollection.Identifier.Solana -> NFTCollection.Identifier.Solana(
collection = value.collection,
)
is SdkNFTCollection.Identifier.Unknown -> NFTCollection.Identifier.Unknown
}
@ -22,6 +25,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter<SdkNFTCollection.Id
is NFTCollection.Identifier.TON -> SdkNFTCollection.Identifier.TON(
contractAddress = value.contractAddress,
)
is NFTCollection.Identifier.Solana -> SdkNFTCollection.Identifier.Solana(
collection = value.collection,
)
is NFTCollection.Identifier.Unknown -> SdkNFTCollection.Identifier.Unknown
}
}

View file

@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/**
* Application preferences store.
@ -19,6 +20,7 @@ import com.squareup.moshi.Types
*/
class AppPreferencesStore(
val moshi: Moshi,
val dispatchers: CoroutineDispatcherProvider,
private val preferencesDataStore: DataStore<Preferences>,
) : DataStore<Preferences> by preferencesDataStore {

View file

@ -5,23 +5,25 @@ import androidx.datastore.preferences.core.edit
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Types
import com.tangem.datasource.local.preferences.AppPreferencesStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
/** Get flow of nullable data [T] by string [key] */
inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String>): Flow<T?> {
val adapter = moshi.adapter(T::class.java)
return data.map { preferences ->
preferences[key]?.let {
try {
adapter.fromJson(it)
} catch (e: JsonDataException) {
null
}
}
}.distinctUntilChanged()
return flow {
val adapter = moshi.adapter(T::class.java)
emitAll(
data.map { preferences ->
preferences[key]?.let {
try {
adapter.fromJson(it)
} catch (e: JsonDataException) {
null
}
}
}.distinctUntilChanged(),
)
}
}
/**
@ -32,16 +34,19 @@ inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String
* @see getObjectList
* */
inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String>, default: T): Flow<T> {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
return data.map {
try {
it[key]?.let(adapter::fromJson) ?: default
} catch (e: JsonDataException) {
default
}
}.distinctUntilChanged()
return flow {
val adapter = moshi.adapter(T::class.java)
emitAll(
data.map {
try {
it[key]?.let(adapter::fromJson) ?: default
} catch (e: JsonDataException) {
default
}
}.distinctUntilChanged(),
)
}
}
/**
* Get nullable data [T] by string [key]
*
@ -49,26 +54,27 @@ inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String
*
* @see getObjectListSync
* */
suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key<String>): T? {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
return data.firstOrNull()
?.get(key)
?.let {
try {
adapter.fromJson(it)
} catch (e: JsonDataException) {
null
suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key<String>): T? =
withContext(dispatchers.io) {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
data.firstOrNull()
?.get(key)
?.let {
try {
adapter.fromJson(it)
} catch (e: JsonDataException) {
null
}
}
}
}
}
/** Get data [T] by string [key]. If data is not found, it returns [default] */
suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrDefault(
key: Preferences.Key<String>,
default: T,
): T {
): T = withContext(dispatchers.io) {
val adapter = moshi.adapter(T::class.java)
return data.firstOrNull()
data.firstOrNull()
?.get(key)
?.let {
try {
@ -87,37 +93,47 @@ suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrDefault(
*
* @see storeObjectList
* */
suspend inline fun <reified T> AppPreferencesStore.storeObject(key: Preferences.Key<String>, value: T) {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
edit { it[key] = adapter.toJson(value) }
}
@Suppress("OptionalUnit")
suspend inline fun <reified T> AppPreferencesStore.storeObject(key: Preferences.Key<String>, value: T): Unit =
withContext(dispatchers.io) {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
edit { it[key] = adapter.toJson(value) }
}
/** Store list of data [value] by string [key] */
suspend inline fun <reified T> AppPreferencesStore.storeObjectList(key: Preferences.Key<String>, value: List<T>) {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
edit { it[key] = adapter.toJson(value) }
}
suspend inline fun <reified T> AppPreferencesStore.storeObjectList(key: Preferences.Key<String>, value: List<T>) =
withContext(dispatchers.io) {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
edit { it[key] = adapter.toJson(value) }
}
/** Get flow of list of data [T] by string [key]. If data is not found, it returns `null` */
inline fun <reified T> AppPreferencesStore.getObjectList(key: Preferences.Key<String>): Flow<List<T>?> {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
return data.map { it[key]?.let(adapter::fromJson) }.distinctUntilChanged()
return flow {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
emitAll(
data.map {
it[key]?.let(adapter::fromJson)
}.distinctUntilChanged(),
)
}
}
/** Get list of data [T] by string [key], or empty if data is not found */
suspend inline fun <reified T> AppPreferencesStore.getObjectListSync(key: Preferences.Key<String>): List<T> {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
suspend inline fun <reified T> AppPreferencesStore.getObjectListSync(key: Preferences.Key<String>): List<T> =
withContext(dispatchers.io) {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
/** Store map with [String] key and value [V] by string [key] */
suspend inline fun <reified V> AppPreferencesStore.storeObjectMap(
key: Preferences.Key<String>,
value: Map<String, V>,
) {
) = withContext(dispatchers.io) {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
@ -125,37 +141,47 @@ suspend inline fun <reified V> AppPreferencesStore.storeObjectMap(
}
/** Get map with [String] key and value [V] by string [key], or empty if data is not found */
suspend inline fun <reified V> AppPreferencesStore.getObjectMapSync(key: Preferences.Key<String>): Map<String, V> {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
suspend inline fun <reified V> AppPreferencesStore.getObjectMapSync(key: Preferences.Key<String>): Map<String, V> =
withContext(dispatchers.io) {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
/** Get flow of map with [String] key and value [V] by string [key], or empty if data is not found */
inline fun <reified V> AppPreferencesStore.getObjectMap(key: Preferences.Key<String>): Flow<Map<String, V>> {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return flow {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() }
emitAll(
data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() },
)
}
}
/** Get set of data [T] by string [key], or empty if data is not found */
suspend inline fun <reified T> AppPreferencesStore.getObjectSetSync(key: Preferences.Key<String>): Set<T> {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
suspend inline fun <reified T> AppPreferencesStore.getObjectSetSync(key: Preferences.Key<String>): Set<T> =
withContext(dispatchers.io) {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
/** Get flow of set of [T] by string [key], or empty if data is not found */
inline fun <reified T> AppPreferencesStore.getObjectSet(key: Preferences.Key<String>): Flow<Set<T>> {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
return data.map {
it[key]?.let(adapter::fromJson) ?: emptySet()
return flow {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
emitAll(
data.map {
it[key]?.let(adapter::fromJson) ?: emptySet()
},
)
}
}

View file

@ -22,7 +22,9 @@ internal class SharedPreferencesKeyMigration(
private val keyName: String,
) : DataMigration<Preferences> {
private val legacyPrefs = context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE)
private val legacyPrefs by lazy {
context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE)
}
override suspend fun cleanUp() {
val sharedPrefsEditor = legacyPrefs.edit()

View file

@ -10,13 +10,24 @@ import com.tangem.utils.extensions.orZero
/**
* Converter from [QuotesResponse.Quote] to [Quote.Value]
*
* @property isCached flag that determines whether the quote is a cache
* @property source status source
*
[REDACTED_AUTHOR]
*/
internal class QuoteConverter(private val isCached: Boolean) :
class QuoteConverter(
private val source: StatusSource,
) :
Converter<Map.Entry<String, QuotesResponse.Quote>, Quote.Value> {
/**
* Secondary constructor
*
* @param isCached flag that determines whether the quote is a cache
*/
constructor(isCached: Boolean) : this(
source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL,
)
override fun convert(value: Map.Entry<String, QuotesResponse.Quote>): Quote.Value {
val (currencyId, quote) = value
@ -24,7 +35,7 @@ internal class QuoteConverter(private val isCached: Boolean) :
rawCurrencyId = CryptoCurrency.RawID(currencyId),
fiatRate = quote.price.orZero(),
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL,
source = source,
)
}
}

View file

@ -7,7 +7,7 @@ import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.model.stakekit.YieldBalanceItem
import com.tangem.utils.converter.Converter
internal class YieldBalanceConverter(
class YieldBalanceConverter(
private val source: StatusSource,
) : Converter<YieldBalanceWrapperDTO, YieldBalance> {

View file

@ -6,6 +6,7 @@ import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
import com.tangem.datasource.api.common.config.ApiConfig.Companion.DEBUG_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.EXTERNAL_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.INTERNAL_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE
@ -90,6 +91,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
is TangemVisaAuth -> createVisaAuthModel()
is TangemVisa -> createVisaModel()
is TangemCardSdk -> createTangemCardSdkModel()
is BlockAid -> createBlockAidSdkModel()
}
}
@ -100,6 +102,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> ApiEnvironment.STAGE
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
@ -115,6 +118,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> "[REDACTED_ENV_URL]"
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> "https://express.tangem.com/v1/"
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
@ -215,6 +219,16 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
)
}
private fun createBlockAidSdkModel(): Model {
return Model(
id = ApiConfig.ID.BlockAid,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.blockaid.io/v0/",
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]

View file

@ -7,6 +7,7 @@ import com.squareup.moshi.Types
import com.squareup.moshi.adapter
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerifyOrder
import io.mockk.every
@ -22,7 +23,11 @@ class AssetLoaderTest {
private val assetReader = mockk<AssetReader>()
private val moshi = mockk<Moshi>()
private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi)
private val assetLoader = AssetLoader(
assetReader = assetReader,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun load() = runTest {

View file

@ -2,7 +2,6 @@ package com.tangem.datasource.asset.reader
import android.content.res.AssetManager
import com.google.common.truth.Truth
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@ -15,7 +14,7 @@ import java.io.IOException
internal class AndroidAssetReaderTest {
private val assetManager = mockk<AssetManager>()
private val assetReader = AndroidAssetReader(assetManager, TestingCoroutineDispatcherProvider())
private val assetReader = AndroidAssetReader(assetManager)
@Test
fun read_content() = runTest {