diff --git a/app/build.gradle.kts b/app/build.gradle.kts index efdd10e6d0..1094a3f88a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,6 +102,8 @@ dependencies { implementation(projects.domain.walletConnect) implementation(projects.domain.markets) implementation(projects.domain.manageTokens) + implementation(projects.domain.nft) + implementation(projects.domain.nft.models) implementation(projects.domain.onramp) implementation(projects.domain.promo) implementation(projects.domain.promo.models) @@ -144,6 +146,7 @@ dependencies { implementation(projects.data.walletConnect) implementation(projects.data.markets) implementation(projects.data.manageTokens) + implementation(projects.data.nft) implementation(projects.data.onramp) /** Features */ diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 97550bec4e..380fdce973 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.domain.staking.models) implementation(projects.domain.onramp.models) implementation(projects.domain.models) + implementation(projects.domain.nft.models) /** Tangem libraries */ implementation(tangemDeps.blockchain) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index 21c50703c5..700b510390 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -3,6 +3,8 @@ package com.tangem.datasource.di import com.squareup.moshi.Moshi import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.tangem.blockchain.nft.models.NFTAsset +import com.tangem.blockchain.nft.models.NFTCollection import com.tangem.common.json.MoshiJsonConverter import com.tangem.datasource.api.common.adapter.BigDecimalAdapter import com.tangem.datasource.api.common.adapter.DateTimeAdapter @@ -45,6 +47,18 @@ class MoshiModule { .withSubtype(NetworkStatusDM.Verified::class.java, "amounts") .withSubtype(NetworkStatusDM.NoAccount::class.java, "amount_to_create_account"), ) + .add( + PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc") + .withSubtype(NFTCollection.Identifier.EVM::class.java, "evm") + .withSubtype(NFTCollection.Identifier.TON::class.java, "ton") + .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") + .withDefaultValue(NFTAsset.Identifier.Unknown), + ) .addLast(KotlinJsonAdapterFactory()) .addStakeKitEnumFallbackAdapters() .build() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModel.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModel.kt index f65ae01ded..1a5d098731 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModel.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModel.kt @@ -36,6 +36,8 @@ class EnvironmentConfigModel( @Json(name = "bittensorOnfinalityKey") val bittensorOnfinalityKey: String?, @Json(name = "koinosProApiKey") val koinosProApiKey: String?, @Json(name = "alephiumTangemApiKey") val alephiumTangemApiKey: String?, + @Json(name = "moralisApiKey") val moralisApiKey: String?, + @Json(name = "nftScanApiKey") val nftScanApiKey: String?, ) @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt new file mode 100644 index 0000000000..43d82aaa8d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt @@ -0,0 +1,49 @@ +package com.tangem.datasource.local.nft + +import androidx.datastore.core.DataStore +import com.tangem.blockchain.nft.models.NFTAsset +import com.tangem.blockchain.nft.models.NFTCollection +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map + +internal class DefaultNFTPersistenceStore( + private val collectionsPersistenceStore: DataStore>, + private val pricesPersistenceStore: DataStore>, +) : NFTPersistenceStore { + + override fun getCollections(): Flow> = collectionsPersistenceStore.data + + override suspend fun getCollectionsSync(): List? = collectionsPersistenceStore + .data + .firstOrNull() + + override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow = + collectionsPersistenceStore.data + .map { collection -> + collection + .firstOrNull { it.identifier == collectionId } + ?.getAsset(assetId) + } + + override fun getSalePrice(assetId: NFTAsset.Identifier): Flow = pricesPersistenceStore.data + .map { it[assetId] } + + override suspend fun getSalePricesSync(): Map? = pricesPersistenceStore + .data + .firstOrNull() + + override suspend fun saveCollections(collections: List) { + collectionsPersistenceStore.updateData { + collections + } + } + + override suspend fun saveSalePrice(assetId: NFTAsset.Identifier, salePrice: NFTAsset.SalePrice) { + pricesPersistenceStore.updateData { + it.toMutableMap().apply { this[assetId] = salePrice } + } + } + + private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier) = assets.firstOrNull { it.identifier == assetId } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt new file mode 100644 index 0000000000..5725c0aecf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt @@ -0,0 +1,111 @@ +package com.tangem.datasource.local.nft + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.models.NFTSalePrice +import com.tangem.domain.tokens.model.Network +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map + +internal class DefaultNFTRuntimeStore( + private val network: Network, + private val collectionsRuntimeStore: RuntimeSharedStore, + private val pricesRuntimeStore: RuntimeSharedStore>, +) : NFTRuntimeStore { + + override suspend fun initialize(collections: NFTCollections, prices: Map) { + collectionsRuntimeStore.store(collections) + pricesRuntimeStore.store(prices) + } + + override fun getCollections(): Flow = collectionsRuntimeStore + .get() + .combine(pricesRuntimeStore.get(), ::Pair) + .map { + val (collectionsData, prices) = it + collectionsData.mergeWithPrices(prices) + } + + override suspend fun getCollectionsSync(): NFTCollections { + val collectionsData = collectionsRuntimeStore.getSyncOrNull() + val prices = pricesRuntimeStore.getSyncOrNull() + return collectionsData + ?.mergeWithPrices(prices.orEmpty()) + ?: NFTCollections( + network = network, + content = NFTCollections.Content.Collections( + collections = null, + source = StatusSource.CACHE, + ), + ) + } + + override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow = + collectionsRuntimeStore + .get() + .combine(getSalePrice(assetId), ::Pair) + .map { + val (collectionsData, price) = it + collectionsData + .getCollection(collectionId) + ?.getAsset(assetId) + ?.mergeWithPrice(price) + ?: NFTAsset.Error(assetId) + } + + override fun getSalePrice(assetId: NFTAsset.Identifier): Flow = pricesRuntimeStore + .get() + .map { it[assetId] ?: NFTSalePrice.Empty(assetId) } + + override suspend fun saveCollections(collections: NFTCollections) { + collectionsRuntimeStore.store(collections) + } + + override suspend fun saveSalePrice(salePrice: NFTSalePrice) { + pricesRuntimeStore.update(emptyMap()) { + it.plus(salePrice.assetId to salePrice) + } + } + + private fun NFTCollections.getCollection(collectionId: NFTCollection.Identifier): NFTCollection? = + (content as? NFTCollections.Content.Collections) + ?.collections + ?.firstOrNull { it.id == collectionId } + + private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier): NFTAsset? = + assets.firstOrNull { it.id == assetId } + + private fun NFTCollections.mergeWithPrices(prices: Map): NFTCollections = + when (val content = this.content) { + is NFTCollections.Content.Collections -> { + this.copy( + content = content.mergeWithPrices(prices), + ) + } + is NFTCollections.Content.Error -> this + } + + private fun NFTCollections.Content.Collections.mergeWithPrices(prices: Map) = + copy( + collections = this.collections?.map { data -> + data.copy( + assets = data.assets.map { asset -> + asset.mergeWithPrice(prices[asset.id] ?: NFTSalePrice.Empty(asset.id)) + }, + ) + }, + source = this.source, + ) + + private fun NFTAsset.mergeWithPrice(price: NFTSalePrice): NFTAsset = when (this) { + is NFTAsset.Error -> this + is NFTAsset.Value -> copy( + salePrice = price, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStore.kt new file mode 100644 index 0000000000..ecd7275a6a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStore.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.local.nft + +import com.tangem.blockchain.nft.models.NFTAsset +import com.tangem.blockchain.nft.models.NFTCollection +import kotlinx.coroutines.flow.Flow + +interface NFTPersistenceStore { + fun getCollections(): Flow> + + suspend fun getCollectionsSync(): List? + + fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow + + fun getSalePrice(assetId: NFTAsset.Identifier): Flow + + suspend fun getSalePricesSync(): Map? + + suspend fun saveCollections(collections: List) + + suspend fun saveSalePrice(assetId: NFTAsset.Identifier, salePrice: NFTAsset.SalePrice) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt new file mode 100644 index 0000000000..36b72f71d8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt @@ -0,0 +1,54 @@ +package com.tangem.datasource.local.nft + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.tangem.blockchain.nft.models.NFTAsset +import com.tangem.blockchain.nft.models.NFTCollection +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.listTypes +import com.tangem.datasource.utils.mapWithCustomKeyTypes +import com.tangem.domain.tokens.model.Network +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NFTPersistenceStoreFactory @Inject constructor( + @NetworkMoshi private val moshi: Moshi, + @ApplicationContext private val context: Context, + private val dispatchers: CoroutineDispatcherProvider, +) { + + fun provide(network: Network): NFTPersistenceStore { + return DefaultNFTPersistenceStore( + collectionsPersistenceStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = listTypes(), + defaultValue = emptyList(), + ), + produceFile = { + context.dataStoreFile(fileName = "nft_${network.name}_${network.derivationPath}_collections") + }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ), + pricesPersistenceStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithCustomKeyTypes(), + defaultValue = emptyMap(), + ), + produceFile = { + context.dataStoreFile(fileName = "nft_${network.name}_${network.derivationPath}_prices") + }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ), + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt new file mode 100644 index 0000000000..e9b27f5d0c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.local.nft + +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.models.NFTSalePrice +import kotlinx.coroutines.flow.Flow + +interface NFTRuntimeStore { + + suspend fun initialize(collections: NFTCollections, prices: Map) + + fun getCollections(): Flow + + suspend fun getCollectionsSync(): NFTCollections + + fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow + + fun getSalePrice(assetId: NFTAsset.Identifier): Flow + + suspend fun saveCollections(collections: NFTCollections) + + suspend fun saveSalePrice(salePrice: NFTSalePrice) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStoreFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStoreFactory.kt new file mode 100644 index 0000000000..a3c95a42b3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStoreFactory.kt @@ -0,0 +1,16 @@ +package com.tangem.datasource.local.nft + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.tokens.model.Network +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NFTRuntimeStoreFactory @Inject constructor() { + + fun provide(network: Network): NFTRuntimeStore = DefaultNFTRuntimeStore( + network = network, + collectionsRuntimeStore = RuntimeSharedStore(), + pricesRuntimeStore = RuntimeSharedStore(), + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt new file mode 100644 index 0000000000..ce67e88dd6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt @@ -0,0 +1,55 @@ +package com.tangem.datasource.local.nft.converter + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTSalePrice +import com.tangem.domain.tokens.model.Network +import com.tangem.utils.converter.Converter +import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset + +class NFTSdkAssetConverter( + private val nftSdkAssetIdentifierConverter: NFTSdkAssetIdentifierConverter, + private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter, +) : Converter, NFTAsset> { + override fun convert(value: Pair): NFTAsset { + val (network, asset) = value + val assetId = nftSdkAssetIdentifierConverter.convert(asset.identifier) + val collectionId = nftSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier) + return NFTAsset.Value( + id = assetId, + collectionId = collectionId, + network = network, + contractType = asset.contractType, + owner = asset.owner, + name = asset.name, + description = asset.description, + salePrice = asset.salePrice?.let { + NFTSalePrice.Value( + assetId = assetId, + value = it.value, + symbol = it.symbol, + source = StatusSource.CACHE, + ) + } ?: NFTSalePrice.Empty(assetId = assetId), + rarity = asset.rarity?.let { + NFTAsset.Value.Rarity( + rank = it.rank, + label = it.label, + ) + }, + media = asset.media?.let { + NFTAsset.Value.Media( + url = it.url, + mimetype = it.mimetype, + ) + }, + traits = asset.traits.map { + NFTAsset.Value.Trait( + name = it.name, + value = it.value, + ) + }, + source = StatusSource.CACHE, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt new file mode 100644 index 0000000000..db75460ab1 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.datasource.local.nft.converter + +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.utils.converter.TwoWayConverter +import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset + +class NFTSdkAssetIdentifierConverter : TwoWayConverter { + override fun convert(value: SdkNFTAsset.Identifier): NFTAsset.Identifier = when (value) { + is SdkNFTAsset.Identifier.EVM -> NFTAsset.Identifier.EVM( + tokenId = value.tokenId, + tokenAddress = value.tokenAddress, + ) + is SdkNFTAsset.Identifier.TON -> NFTAsset.Identifier.TON( + tokenAddress = value.tokenAddress, + ) + is SdkNFTAsset.Identifier.Unknown -> error("unknown asset id") + } + + override fun convertBack(value: NFTAsset.Identifier): SdkNFTAsset.Identifier = when (value) { + is NFTAsset.Identifier.EVM -> SdkNFTAsset.Identifier.EVM( + tokenId = value.tokenId, + tokenAddress = value.tokenAddress, + ) + is NFTAsset.Identifier.TON -> SdkNFTAsset.Identifier.TON( + tokenAddress = value.tokenAddress, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt new file mode 100644 index 0000000000..70fb511bad --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt @@ -0,0 +1,27 @@ +package com.tangem.datasource.local.nft.converter + +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.domain.tokens.model.Network +import com.tangem.utils.converter.Converter +import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection + +class NFTSdkCollectionConverter( + private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter, + private val nftSdkAssetConverter: NFTSdkAssetConverter, +) : Converter, NFTCollection> { + override fun convert(value: Pair): NFTCollection { + val (network, collection) = value + val collectionId = nftSdkCollectionIdentifierConverter.convert(collection.identifier) + return NFTCollection( + id = collectionId, + network = network, + name = collection.name, + description = collection.description, + logoUrl = collection.logoUrl, + count = collection.count, + assets = collection.assets.map { asset -> + nftSdkAssetConverter.convert(network to asset) + }, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt new file mode 100644 index 0000000000..9c2a5d385d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.local.nft.converter + +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.utils.converter.TwoWayConverter +import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection + +class NFTSdkCollectionIdentifierConverter : TwoWayConverter { + override fun convert(value: SdkNFTCollection.Identifier): NFTCollection.Identifier = when (value) { + is SdkNFTCollection.Identifier.EVM -> NFTCollection.Identifier.EVM( + tokenAddress = value.tokenAddress, + ) + is SdkNFTCollection.Identifier.TON -> NFTCollection.Identifier.TON( + contractAddress = value.contractAddress, + ) + is SdkNFTCollection.Identifier.Unknown -> error("unknown collection id") + } + + override fun convertBack(value: NFTCollection.Identifier): SdkNFTCollection.Identifier = when (value) { + is NFTCollection.Identifier.EVM -> SdkNFTCollection.Identifier.EVM( + tokenAddress = value.tokenAddress, + ) + is NFTCollection.Identifier.TON -> SdkNFTCollection.Identifier.TON( + contractAddress = value.contractAddress, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiTypesExt.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiTypesExt.kt index cc20669688..0a90d788cf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiTypesExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiTypesExt.kt @@ -11,6 +11,10 @@ inline fun mapWithStringKeyTypes(): ParameterizedType { return Types.newParameterizedType(Map::class.java, String::class.java, T::class.java) } +inline fun mapWithCustomKeyTypes(): ParameterizedType { + return Types.newParameterizedType(Map::class.java, K::class.java, T::class.java) +} + inline fun listTypes(): ParameterizedType { return Types.newParameterizedType(List::class.java, T::class.java) } diff --git a/data/nft/.gitignore b/data/nft/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/data/nft/.gitignore @@ -0,0 +1 @@ +/build diff --git a/data/nft/build.gradle.kts b/data/nft/build.gradle.kts new file mode 100644 index 0000000000..8d141c90e8 --- /dev/null +++ b/data/nft/build.gradle.kts @@ -0,0 +1,51 @@ +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.ksp) + id("configuration") +} + +android { + namespace = "com.tangem.data.nft" +} + +dependencies { + + /** Project - Data */ + implementation(projects.core.datasource) + implementation(projects.data.common) + + /** Project - Domain */ + implementation(projects.domain.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.nft) + implementation(projects.domain.nft.models) + + /** Project - Utils */ + implementation(projects.core.utils) + implementation(projects.domain.legacy) + implementation(projects.libs.blockchainSdk) + + /** Libs - Other */ + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + implementation(deps.arrow.fx) + implementation(deps.jodatime) + implementation(deps.timber) + implementation(deps.androidx.paging.runtime) + implementation(deps.moshi.kotlin) + ksp(deps.moshi.kotlin.codegen) + kaptForObfuscatingVariants(deps.retrofit.response.type.keeper) + + /** Libs - Tangem */ + implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) + + /** DI */ + implementation(deps.hilt.core) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt new file mode 100644 index 0000000000..7eb91943cf --- /dev/null +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -0,0 +1,227 @@ +package com.tangem.data.nft + +import arrow.core.Either +import com.tangem.blockchain.nft.models.NFTCollection +import com.tangem.datasource.local.nft.NFTPersistenceStore +import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory +import com.tangem.datasource.local.nft.NFTRuntimeStore +import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory +import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter +import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter +import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter +import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.models.NFTSalePrice +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import javax.inject.Inject +import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection + +internal class DefaultNFTRepository @Inject constructor( + private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory, + private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory, + private val walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, +) : NFTRepository { + + private val scope = CoroutineScope(dispatchers.io + SupervisorJob()) + + private val jobs = mutableMapOf() + + private val nftRuntimeStores = mutableMapOf() + private val nftPersistenceStores = mutableMapOf() + + private val nftSdkAssetIdentifierConverter = NFTSdkAssetIdentifierConverter() + private val nftSdkCollectionIdentifierConverter = NFTSdkCollectionIdentifierConverter() + private val nftSdkAssetConverter = NFTSdkAssetConverter( + nftSdkAssetIdentifierConverter = nftSdkAssetIdentifierConverter, + nftSdkCollectionIdentifierConverter = nftSdkCollectionIdentifierConverter, + ) + private val collectionConverter = NFTSdkCollectionConverter( + nftSdkCollectionIdentifierConverter = nftSdkCollectionIdentifierConverter, + nftSdkAssetConverter = nftSdkAssetConverter, + ) + + override fun observeCollections(userWalletId: UserWalletId, networks: List): Flow> = + flow { + emitAll( + combine( + networks.map { getNFTRuntimeStore(it).getCollections() }, + ) { it.asList() }, + ) + }.onStart { + refreshCollections(userWalletId, networks) + } + + override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List) { + networks.map { network -> + scope.launch { + expireCollections(network) + + Either.catch { + walletManagersFacade.getNFTCollections(userWalletId, network) + }.onLeft { + saveFailedStateInRuntime( + network = network, + error = it, + ) + }.onRight { + val mergedCollections = it.mergeWithStoredAssets(network) + + saveCollectionsInRuntime( + network = network, + collections = mergedCollections, + ) + saveCollectionsInPersistence( + network = network, + collections = mergedCollections, + ) + } + }.saveIn(getJobHolder(network)) + }.joinAll() + } + + private suspend fun expireCollections(network: Network) { + val runtimeStore = getNFTRuntimeStore(network) + + val expiredCollections = runtimeStore + .getCollectionsSync() + .let { collections -> + collections.copy( + content = when (val content = collections.content) { + is NFTCollections.Content.Collections -> content.copy( + source = StatusSource.CACHE, + ) + is NFTCollections.Content.Error -> content + }, + ) + } + runtimeStore.saveCollections(expiredCollections) + } + + private suspend fun saveCollectionsInRuntime(network: Network, collections: List) { + getNFTRuntimeStore(network).saveCollections( + NFTCollections( + network = network, + content = NFTCollections.Content.Collections( + collections = collections.map { collection -> + collectionConverter.convert(network to collection) + }, + source = StatusSource.ACTUAL, + ), + ), + ) + } + + private suspend fun saveFailedStateInRuntime(network: Network, error: Throwable) { + getNFTRuntimeStore(network).saveCollections( + NFTCollections( + network = network, + content = NFTCollections.Content.Error( + error = error, + ), + ), + ) + } + + private suspend fun saveCollectionsInPersistence(network: Network, collections: List) { + getNFTPersistenceStore(network).saveCollections(collections) + } + + private fun getJobHolder(network: Network): JobHolder = jobs[network] ?: run { + JobHolder().also { + jobs[network] = it + } + } + + private fun getNFTPersistenceStore(network: Network): NFTPersistenceStore = nftPersistenceStores[network] ?: run { + nftPersistenceStoreFactory.provide(network).also { + nftPersistenceStores[network] = it + } + } + + private suspend fun getNFTRuntimeStore(network: Network): NFTRuntimeStore = nftRuntimeStores[network] ?: run { + nftRuntimeStoreFactory.provide(network).also { + nftRuntimeStores[network] = it + val storedCollections = getStoredCollections(network) + val storedPrices = getStoredPrices(network) + it.initialize( + collections = storedCollections, + prices = storedPrices, + ) + } + } + + private suspend fun getStoredCollections(network: Network) = getNFTPersistenceStore(network) + .getCollectionsSync() + .let { + NFTCollections( + network = network, + content = NFTCollections.Content.Collections( + collections = it?.map { collection -> + collectionConverter.convert(network to collection) + }, + source = StatusSource.CACHE, + ), + ) + } + + private suspend fun getStoredPrices(network: Network) = getNFTPersistenceStore(network) + .getSalePricesSync() + .orEmpty() + .let { prices -> + prices + .mapKeys { + val (assetId, _) = it + nftSdkAssetIdentifierConverter.convert(assetId) + } + .mapValues { + val (assetId, price) = it + NFTSalePrice.Value( + assetId = assetId, + value = price.value, + symbol = price.symbol, + source = StatusSource.CACHE, + ) + } + } + + private suspend fun List.mergeWithStoredAssets(network: Network): List { + val storedCollections = + getNFTPersistenceStore(network) + .getCollectionsSync() + .orEmpty() + .associateBy(NFTCollection::identifier) + + return this.map { updatedCollection -> + if (!storedCollections.containsKey(updatedCollection.identifier)) { + // if there is no collection in cache, then write a new one + updatedCollection + } else if (updatedCollection.assets.isNotEmpty()) { + // if there are assets in a new collection, then write this + updatedCollection + } else { + // otherwise, just update a stored collection fields + storedCollections[updatedCollection.identifier] + ?.copy( + name = updatedCollection.name, + count = updatedCollection.count, + description = updatedCollection.description, + logoUrl = updatedCollection.logoUrl, + ) + ?: updatedCollection + } + } + } +} \ No newline at end of file diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/di/NFTDataModule.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/di/NFTDataModule.kt new file mode 100644 index 0000000000..c0af92def9 --- /dev/null +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/di/NFTDataModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.nft.di + +import com.tangem.data.nft.DefaultNFTRepository +import com.tangem.domain.nft.repository.NFTRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface NFTDataModule { + + @Binds + @Singleton + fun bindNFTRepository(repository: DefaultNFTRepository): NFTRepository +} \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt index 9811e8d408..ce2a817c95 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -15,6 +15,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.trustlines.AssetRequirementsManager import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.blockchain.nft.models.NFTAsset +import com.tangem.blockchain.nft.models.NFTCollection import com.tangem.blockchain.transactionhistory.models.TransactionHistoryRequest import com.tangem.blockchainsdk.BlockchainSDKFactory import com.tangem.crypto.hdWallet.DerivationPath @@ -639,6 +641,47 @@ class DefaultWalletManagersFacade( return (walletManager as? UtxoBlockchainManager)?.allowConsolidation == true } + override suspend fun getNFTCollections(userWalletId: UserWalletId, network: Network): List { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) ?: return emptyList() + val address = walletManager.wallet.address + return walletManager.getCollections(address) + } + + override suspend fun getNFTAssets( + userWalletId: UserWalletId, + network: Network, + collectionIdentifier: NFTCollection.Identifier, + ): List { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) ?: return emptyList() + val address = walletManager.wallet.address + return walletManager.getAssets(address, collectionIdentifier) + } + + override suspend fun getAsset( + userWalletId: UserWalletId, + network: Network, + collectionIdentifier: NFTCollection.Identifier, + assetIdentifier: NFTAsset.Identifier, + ): NFTAsset? { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) ?: return null + return walletManager.getAsset(collectionIdentifier, assetIdentifier) + } + private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set) { if (tokens.isEmpty()) return diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt index 7eb56b21c8..d1554a2118 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -10,6 +10,8 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.blockchain.nft.models.NFTAsset +import com.tangem.blockchain.nft.models.NFTCollection import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.models.AssetRequirementsCondition @@ -248,4 +250,19 @@ interface WalletManagersFacade { * @param network availability for network */ suspend fun checkUtxoConsolidationAvailability(userWalletId: UserWalletId, network: Network): Boolean + + suspend fun getNFTCollections(userWalletId: UserWalletId, network: Network): List + + suspend fun getNFTAssets( + userWalletId: UserWalletId, + network: Network, + collectionIdentifier: NFTCollection.Identifier, + ): List + + suspend fun getAsset( + userWalletId: UserWalletId, + network: Network, + collectionIdentifier: NFTCollection.Identifier, + assetIdentifier: NFTAsset.Identifier, + ): NFTAsset? } \ No newline at end of file diff --git a/domain/nft/.gitignore b/domain/nft/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/domain/nft/.gitignore @@ -0,0 +1 @@ +/build diff --git a/domain/nft/build.gradle.kts b/domain/nft/build.gradle.kts new file mode 100644 index 0000000000..2159e615a7 --- /dev/null +++ b/domain/nft/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +android { + namespace = "com.tangem.domain.nft" +} + +dependencies { + implementation(deps.arrow.core) + implementation(deps.kotlin.coroutines) + + implementation(projects.domain.core) + implementation(projects.domain.models) + implementation(projects.domain.nft.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets.models) +} \ No newline at end of file diff --git a/domain/nft/models/.gitignore b/domain/nft/models/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/domain/nft/models/.gitignore @@ -0,0 +1 @@ +/build diff --git a/domain/nft/models/build.gradle.kts b/domain/nft/models/build.gradle.kts new file mode 100644 index 0000000000..92208c0a6b --- /dev/null +++ b/domain/nft/models/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +dependencies { + implementation(deps.arrow.core) + implementation(deps.kotlin.coroutines) + + implementation(projects.domain.models) + + implementation(projects.domain.tokens.models) +} \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt new file mode 100644 index 0000000000..ded2b25e41 --- /dev/null +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt @@ -0,0 +1,52 @@ +package com.tangem.domain.nft.models + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Network + +sealed class NFTAsset { + abstract val id: Identifier + + data class Value( + override val id: Identifier, + val collectionId: NFTCollection.Identifier, + val network: Network, + val contractType: String, + val owner: String?, + val name: String?, + val description: String?, + val salePrice: NFTSalePrice, + val rarity: Rarity?, + val media: Media?, + val traits: List, + val source: StatusSource, + ) : NFTAsset() { + + data class Media( + val mimetype: String, + val url: String, + ) + + data class Rarity( + val rank: String, + val label: String, + ) + + data class Trait( + val name: String, + val value: String, + ) + } + + data class Error( + override val id: Identifier, + ) : NFTAsset() + + sealed class Identifier { + data class EVM( + val tokenId: String, + val tokenAddress: String, + ) : Identifier() + + data class TON(val tokenAddress: String) : Identifier() + } +} \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt new file mode 100644 index 0000000000..27e9ad4737 --- /dev/null +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.nft.models + +import com.tangem.domain.tokens.model.Network + +data class NFTCollection( + val id: Identifier, + val network: Network, + val name: String?, + val description: String?, + val logoUrl: String?, + val count: Int, + val assets: List = emptyList(), +) { + sealed class Identifier { + data class EVM(val tokenAddress: String) : Identifier() + data class TON(val contractAddress: String?) : Identifier() + } +} \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt new file mode 100644 index 0000000000..b4e4accc54 --- /dev/null +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.nft.models + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Network + +data class NFTCollections( + val network: Network, + val content: Content, +) { + sealed class Content { + data class Collections( + val collections: List?, + val source: StatusSource, + ) : Content() + + data class Error( + val error: Throwable, + ) : Content() + } +} \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt new file mode 100644 index 0000000000..3ef25c436e --- /dev/null +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.nft.models + +import com.tangem.domain.models.StatusSource +import java.math.BigDecimal + +sealed class NFTSalePrice { + abstract val assetId: NFTAsset.Identifier + + data class Empty( + override val assetId: NFTAsset.Identifier, + ) : NFTSalePrice() + + data class Error( + override val assetId: NFTAsset.Identifier, + ) : NFTSalePrice() + + data class Value( + override val assetId: NFTAsset.Identifier, + val value: BigDecimal, + val symbol: String, + val source: StatusSource, + ) : NFTSalePrice() +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt new file mode 100644 index 0000000000..9a220eb202 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.nft.repository + +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow + +interface NFTRepository { + fun observeCollections(userWalletId: UserWalletId, networks: List): Flow> + + suspend fun refreshCollections(userWalletId: UserWalletId, networks: List) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 35ef36ff52..6bda05b59e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -275,6 +275,8 @@ include(":domain:manage-tokens:models") include(":domain:onramp") include(":domain:onramp:models") include(":domain:promo") +include(":domain:nft") +include(":domain:nft:models") // endregion Domain modules // region Data modules @@ -298,6 +300,7 @@ include(":data:staking") include(":data:wallet-connect") include(":data:markets") include(":data:manage-tokens") +include(":data:nft") include(":data:onramp") // endregion Data modules include(":domain:promo:models") \ No newline at end of file