Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+67
View File
@@ -0,0 +1,67 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
apply from: '../scripts/secrets.gradle'
android {
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
namespace 'io.novafoundation.nova.feature_nft_impl'
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(':core-db')
implementation project(':common')
implementation project(':feature-wallet-api')
implementation project(':feature-account-api')
implementation project(':feature-nft-api')
implementation project(':runtime')
implementation kotlinDep
implementation androidDep
implementation materialDep
implementation constraintDep
implementation coroutinesDep
implementation coroutinesAndroidDep
implementation viewModelKtxDep
implementation liveDataKtxDep
implementation lifeCycleKtxDep
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation daggerDep
ksp daggerCompiler
implementation roomDep
ksp roomCompiler
implementation lifecycleDep
ksp lifecycleCompiler
testImplementation jUnitDep
testImplementation mockitoDep
implementation substrateSdkDep
implementation gsonDep
implementation retrofitDep
implementation insetterDep
implementation shimmerDep
androidTestImplementation androidTestRunnerDep
androidTestImplementation androidTestRulesDep
androidTestImplementation androidJunitDep
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_nft_impl
interface NftRouter {
fun openNftDetails(nftId: String)
fun back()
}
@@ -0,0 +1,104 @@
package io.novafoundation.nova.feature_nft_impl.data.mappers
import io.novafoundation.nova.common.utils.isZero
import io.novafoundation.nova.core_db.model.NftLocal
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import java.math.BigInteger
fun mapNftTypeLocalToTypeKey(
typeLocal: NftLocal.Type
): Nft.Type.Key = when (typeLocal) {
NftLocal.Type.UNIQUES -> Nft.Type.Key.UNIQUES
NftLocal.Type.RMRK1 -> Nft.Type.Key.RMRKV1
NftLocal.Type.RMRK2 -> Nft.Type.Key.RMRKV2
NftLocal.Type.PDC20 -> Nft.Type.Key.PDC20
NftLocal.Type.KODADOT -> Nft.Type.Key.KODADOT
NftLocal.Type.UNIQUE_NETWORK -> Nft.Type.Key.UNIQUE_NETWORK
}
fun nftIssuance(
typeLocal: NftLocal.IssuanceType,
issuanceTotal: BigInteger?,
issuanceMyEdition: String?,
issuanceMyAmount: BigInteger?
): Nft.Issuance {
return when (typeLocal) {
NftLocal.IssuanceType.UNLIMITED -> Nft.Issuance.Unlimited
NftLocal.IssuanceType.LIMITED -> {
val myEditionInt = issuanceMyEdition?.toIntOrNull()
if (issuanceTotal != null && !issuanceTotal.isZero && myEditionInt != null) {
Nft.Issuance.Limited(max = issuanceTotal.toInt(), edition = myEditionInt)
} else {
Nft.Issuance.Unlimited
}
}
NftLocal.IssuanceType.FUNGIBLE -> if (issuanceTotal != null && issuanceMyAmount != null) {
Nft.Issuance.Fungible(myAmount = issuanceMyAmount, totalSupply = issuanceTotal)
} else {
Nft.Issuance.Unlimited
}
}
}
fun nftIssuance(nftLocal: NftLocal): Nft.Issuance {
require(nftLocal.wholeDetailsLoaded)
return nftIssuance(nftLocal.issuanceType, nftLocal.issuanceTotal, nftLocal.issuanceMyEdition, nftLocal.issuanceMyAmount)
}
fun nftPrice(nftLocal: NftLocal): Nft.Price? {
val price = nftLocal.price
if (price == null || price == BigInteger.ZERO) return null
return when (val units = nftLocal.pricedUnits) {
null -> Nft.Price.NonFungible(price)
else -> Nft.Price.Fungible(units = units, totalPrice = price)
}
}
fun mapNftLocalToNft(
chainsById: Map<ChainId, Chain>,
metaAccount: MetaAccount,
nftLocal: NftLocal
): Nft? {
val chain = chainsById[nftLocal.chainId] ?: return null
val type = when (nftLocal.type) {
NftLocal.Type.UNIQUES -> Nft.Type.Uniques
NftLocal.Type.RMRK1 -> Nft.Type.Rmrk1
NftLocal.Type.RMRK2 -> Nft.Type.Rmrk2
NftLocal.Type.PDC20 -> Nft.Type.Pdc20
NftLocal.Type.KODADOT -> Nft.Type.Kodadot
NftLocal.Type.UNIQUE_NETWORK -> Nft.Type.UniqueNetwork
}
val details = if (nftLocal.wholeDetailsLoaded) {
val issuance = nftIssuance(nftLocal)
Nft.Details.Loaded(
name = nftLocal.name,
label = nftLocal.label,
media = nftLocal.media,
price = nftPrice(nftLocal),
issuance = issuance,
)
} else {
Nft.Details.Loadable
}
return Nft(
identifier = nftLocal.identifier,
instanceId = nftLocal.instanceId,
collectionId = nftLocal.collectionId,
chain = chain,
owner = metaAccount.accountIdIn(chain)!!,
metadataRaw = nftLocal.metadata,
type = type,
details = details
)
}
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_nft_impl.data.network.distributed
enum class FileStorage(val prefix: String, val additionalPaths: List<String>, val defaultHttpsGateway: String?) {
IPFS("ipfs://", listOf("ipfs/"), "https://bucket.chaotic.art/ipfs/"),
HTTPS("https://", emptyList(), null),
HTTP("http://", emptyList(), null);
init {
validateHttpsGateway(defaultHttpsGateway)
}
}
private fun validateHttpsGateway(gateway: String?) {
require(gateway == null || gateway.endsWith("/")) {
"Gateway should end with '/' separator"
}
}
object FileStorageAdapter {
fun String.adoptFileStorageLinkToHttps() = adaptToHttps(this)
fun adaptToHttps(distributedStorageLink: String): String {
val distributedStorage = FileStorage.values().firstOrNull { storage ->
distributedStorageLink.pointsTo(storage)
} ?: FileStorage.IPFS
val gateway = distributedStorage.defaultHttpsGateway ?: return distributedStorageLink
validateHttpsGateway(gateway)
var path = distributedStorageLink.removePrefix(distributedStorage.prefix)
distributedStorage.additionalPaths.forEach {
path = path.removePrefix(it)
}
return "$gateway$path"
}
private fun String.pointsTo(fileStorage: FileStorage) = startsWith(fileStorage.prefix)
}
@@ -0,0 +1,123 @@
package io.novafoundation.nova.feature_nft_impl.data.repository
import android.util.Log
import io.novafoundation.nova.common.data.network.HttpExceptionHandler
import io.novafoundation.nova.common.utils.transformLatestDiffed
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_nft_api.data.repository.NftSyncTrigger
import io.novafoundation.nova.feature_nft_impl.data.mappers.mapNftLocalToNft
import io.novafoundation.nova.feature_nft_impl.data.mappers.mapNftTypeLocalToTypeKey
import io.novafoundation.nova.feature_nft_impl.data.source.JobOrchestrator
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvidersRegistry
import io.novafoundation.nova.runtime.ext.level
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.enabledChainById
import io.novafoundation.nova.runtime.multiNetwork.enabledChains
import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private const val NFT_TAG = "NFT"
class NftRepositoryImpl(
private val nftProvidersRegistry: NftProvidersRegistry,
private val chainRegistry: ChainRegistry,
private val jobOrchestrator: JobOrchestrator,
private val nftDao: NftDao,
private val exceptionHandler: HttpExceptionHandler,
) : NftRepository {
override fun allNftFlow(metaAccount: MetaAccount): Flow<List<Nft>> {
return nftDao.nftsFlow(metaAccount.id)
.map { nftsLocal ->
val chainsById = chainRegistry.enabledChainById()
nftsLocal.mapNotNull { nftLocal ->
mapNftLocalToNft(chainsById, metaAccount, nftLocal)
}
}
}
override fun nftDetails(nftId: String): Flow<NftDetails> {
return flow {
val nftTypeKey = mapNftTypeLocalToTypeKey(nftDao.getNftType(nftId))
val nftProvider = nftProvidersRegistry.get(nftTypeKey)
emitAll(nftProvider.nftDetailsFlow(nftId))
}.catch { throw exceptionHandler.transformException(it) }
}
override fun initialNftSyncTrigger(): Flow<NftSyncTrigger> {
return chainRegistry.enabledChainsFlow()
.map { chains -> chains.filter { nftProvidersRegistry.nftSupported(it) } }
.transformLatestDiffed { emit(NftSyncTrigger(it)) }
}
override suspend fun initialNftSync(
metaAccount: MetaAccount,
forceOverwrite: Boolean,
): Unit = withContext(Dispatchers.IO) {
val chains = chainRegistry.enabledChains()
val syncJobs = chains.flatMap { chain ->
nftSyncJobs(chain, metaAccount, forceOverwrite)
}
syncJobs.joinAll()
}
override suspend fun initialNftSync(metaAccount: MetaAccount, chain: Chain) = withContext(Dispatchers.IO) {
val syncJobs = nftSyncJobs(chain, metaAccount, forceOverwrite = false)
syncJobs.joinAll()
}
private fun CoroutineScope.nftSyncJobs(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean): List<Job> {
return nftProvidersRegistry.get(chain)
.filter { it.canSyncIn(chain) }
.map { nftProvider ->
// launch separate job per each nftProvider
launch {
// prevent whole sync from failing if some particular provider fails
runCatching {
nftProvider.initialNftsSync(chain, metaAccount, forceOverwrite)
Log.d(NFT_TAG, "Completed sync in ${chain.name} using ${nftProvider::class.simpleName}")
}.onFailure {
Log.e(NFT_TAG, "Failed to sync nfts in ${chain.name} using ${nftProvider::class.simpleName}", it)
}
}
}
}
override suspend fun fullNftSync(nft: Nft) = withContext(Dispatchers.IO) {
jobOrchestrator.runUniqueJob(nft.identifier) {
runCatching {
nftProvidersRegistry.get(nft.type.key).nftFullSync(nft)
}.onFailure {
Log.e(NFT_TAG, "Failed to fully sync nft ${nft.identifier} in ${nft.chain.name} with type ${nft.type::class.simpleName}", it)
}
}
}
private fun NftProvider.canSyncIn(chain: Chain): Boolean {
val requiredStage = if (requireFullChainSync) Chain.ConnectionState.FULL_SYNC else Chain.ConnectionState.LIGHT_SYNC
return chain.connectionState.level >= requiredStage.level
}
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_nft_impl.data.source
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.coroutineContext
class JobOrchestrator {
private val runningJobs: MutableSet<String> = Collections.newSetFromMap(ConcurrentHashMap())
private val mutex = Mutex()
suspend fun runUniqueJob(id: String, action: suspend () -> Unit) = mutex.withLock {
if (id in runningJobs) {
return@withLock
}
runningJobs += id
CoroutineScope(coroutineContext).async { action() }
.invokeOnCompletion { runningJobs -= id }
}
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_nft_impl.data.source
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
interface NftProvider {
val requireFullChainSync: Boolean
suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean)
suspend fun nftFullSync(nft: Nft)
fun nftDetailsFlow(nftIdentifier: String): Flow<NftDetails>
}
@@ -0,0 +1,53 @@
package io.novafoundation.nova.feature_nft_impl.data.source
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.KodadotProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.Pdc20Provider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.RmrkV1NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.RmrkV2NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.UniquesNftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.UniqueNetworkNftProvider
import io.novafoundation.nova.runtime.ext.Geneses
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class NftProvidersRegistry(
private val uniquesNftProvider: UniquesNftProvider,
private val rmrkV1NftProvider: RmrkV1NftProvider,
private val rmrkV2NftProvider: RmrkV2NftProvider,
private val pdc20Provider: Pdc20Provider,
private val kodadotProvider: KodadotProvider,
private val uniqueNetworkNftProvider: UniqueNetworkNftProvider,
) {
private val kusamaAssetHubProviders = listOf(uniquesNftProvider, kodadotProvider)
private val kusamaProviders = listOf(rmrkV1NftProvider, rmrkV2NftProvider)
private val polkadotProviders = listOf(pdc20Provider)
private val polkadotAssetHubProviders = listOf(kodadotProvider)
private val uniqueNetworkProviders = listOf(uniqueNetworkNftProvider)
fun get(chain: Chain): List<NftProvider> {
return when (chain.id) {
Chain.Geneses.KUSAMA_ASSET_HUB -> kusamaAssetHubProviders
Chain.Geneses.KUSAMA -> kusamaProviders
Chain.Geneses.POLKADOT -> polkadotProviders
Chain.Geneses.POLKADOT_ASSET_HUB -> polkadotAssetHubProviders
Chain.Geneses.UNIQUE_NETWORK -> uniqueNetworkProviders
else -> emptyList()
}
}
fun nftSupported(chain: Chain): Boolean {
return get(chain).isNotEmpty()
}
fun get(nftTypeKey: Nft.Type.Key): NftProvider {
return when (nftTypeKey) {
Nft.Type.Key.RMRKV1 -> rmrkV1NftProvider
Nft.Type.Key.RMRKV2 -> rmrkV2NftProvider
Nft.Type.Key.UNIQUES -> uniquesNftProvider
Nft.Type.Key.PDC20 -> pdc20Provider
Nft.Type.Key.KODADOT -> kodadotProvider
Nft.Type.Key.UNIQUE_NETWORK -> uniqueNetworkNftProvider
}
}
}
@@ -0,0 +1,147 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.scopeAsync
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.core_db.model.NftLocal
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.addressIn
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice
import io.novafoundation.nova.feature_nft_impl.data.network.distributed.FileStorageAdapter.adoptFileStorageLinkToHttps
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.KodadotApi
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotCollectionRequest
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotMetadataRequest
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotNftRemote
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotNftsRequest
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotCollectionRemote
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotMetadataRemote
import io.novafoundation.nova.runtime.ext.ChainGeneses
import io.novafoundation.nova.runtime.ext.accountIdOf
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigInteger
import kotlinx.coroutines.flow.Flow
private const val NO_COLLECTION_LOCAL_ID = "no_collection_local_id"
class KodadotProvider(
private val api: KodadotApi,
private val nftDao: NftDao,
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
) : NftProvider {
override val requireFullChainSync: Boolean = false
override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) {
val address = metaAccount.addressIn(chain) ?: return
val apiUrl = getApiUrl(chain) ?: return
val request = KodadotNftsRequest(address)
val nfts = api.getNfts(apiUrl, request)
val toSave = nfts.data.nftEntities.map { nftRemote ->
NftLocal(
identifier = nftIdentifier(chain, nftRemote),
metaId = metaAccount.id,
chainId = chain.id,
collectionId = nftRemote.collection?.id ?: NO_COLLECTION_LOCAL_ID,
instanceId = nftRemote.id,
metadata = nftRemote.metadata?.encodeToByteArray(),
type = NftLocal.Type.KODADOT,
wholeDetailsLoaded = true,
name = nftRemote.name,
label = nftRemote.sn,
media = nftRemote.image?.adoptFileStorageLinkToHttps(),
issuanceType = nftRemote.collection?.max?.let { NftLocal.IssuanceType.LIMITED } ?: NftLocal.IssuanceType.UNLIMITED,
issuanceTotal = nftRemote.collection?.max?.let { BigInteger(it) },
issuanceMyAmount = null,
price = nftRemote.price?.let { BigInteger(it) },
pricedUnits = null
)
}
nftDao.insertNftsDiff(NftLocal.Type.KODADOT, chain.id, metaAccount.id, toSave, forceOverwrite)
}
override suspend fun nftFullSync(nft: Nft) {
// do nothing
}
override fun nftDetailsFlow(nftIdentifier: String): Flow<NftDetails> {
return flowOf {
val nftLocal = nftDao.getNft(nftIdentifier)
require(nftLocal.wholeDetailsLoaded) {
"Cannot load details of non fully-synced NFT"
}
val chain = chainRegistry.getChain(nftLocal.chainId)
val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId)
val metadataDeferred = scopeAsync { fetchMetadata(nftLocal, chain) }
val collectionDeferred = scopeAsync { fetchCollection(nftLocal, chain) }
val metadata = metadataDeferred.await()
val collection = collectionDeferred.await()
NftDetails(
identifier = nftLocal.identifier,
chain = chain,
owner = metaAccount.requireAccountIdIn(chain),
creator = collection?.issuer?.let { chain.accountIdOf(it) },
media = metadata?.image?.adoptFileStorageLinkToHttps() ?: nftLocal.media,
name = metadata?.name ?: nftLocal.name ?: nftLocal.instanceId!!,
description = metadata?.description,
issuance = nftIssuance(nftLocal),
price = nftPrice(nftLocal),
collection = collection?.let {
NftDetails.Collection(
id = nftLocal.collectionId,
name = it.name,
media = it.image
)
}
)
}
}
private suspend fun fetchMetadata(nftLocal: NftLocal, chain: Chain): KodadotMetadataRemote? {
val metadataId = nftLocal.metadata?.decodeToString() ?: return null
val apiUrl = getApiUrl(chain) ?: return null
val request = KodadotMetadataRequest(metadataId)
return api.getMetadata(apiUrl, request)
.data
.metadataEntityById
}
private suspend fun fetchCollection(nftLocal: NftLocal, chain: Chain): KodadotCollectionRemote? {
val collectionId = nftLocal.collectionId
if (collectionId == NO_COLLECTION_LOCAL_ID) {
return null
}
val apiUrl = getApiUrl(chain) ?: return null
val request = KodadotCollectionRequest(collectionId)
return api.getCollection(apiUrl, request)
.data
.collectionEntityById
}
private fun nftIdentifier(chain: Chain, nft: KodadotNftRemote): String {
return "kodadot-${chain.id}-${nft.id}"
}
private fun getApiUrl(chain: Chain): String? {
return when (chain.id) {
ChainGeneses.POLKADOT_ASSET_HUB -> KodadotApi.POLKADOT_ASSET_HUB_URL
ChainGeneses.KUSAMA_ASSET_HUB -> KodadotApi.KUSAMA_ASSET_HUB_URL
else -> null
}
}
}
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network
import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotCollectionRequest
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotMetadataRequest
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotNftsRequest
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotCollectionResponse
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotMetadataResponse
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotNftResponse
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Url
interface KodadotApi {
companion object {
const val POLKADOT_ASSET_HUB_URL = "https://ahp.gql.api.kodadot.xyz"
const val KUSAMA_ASSET_HUB_URL = "https://ahk.gql.api.kodadot.xyz"
}
@POST
suspend fun getNfts(@Url url: String, @Body request: KodadotNftsRequest): SubQueryResponse<KodadotNftResponse>
@POST
suspend fun getCollection(@Url url: String, @Body request: KodadotCollectionRequest): SubQueryResponse<KodadotCollectionResponse>
@POST
suspend fun getMetadata(@Url url: String, @Body request: KodadotMetadataRequest): SubQueryResponse<KodadotMetadataResponse>
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request
class KodadotCollectionRequest(collectionId: String) {
val query = """
{
collectionEntityById(id: "$collectionId") {
name
image
issuer
}
}
""".trimIndent()
}
@@ -0,0 +1,15 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request
class KodadotMetadataRequest(metadataId: String) {
val query = """
{
metadataEntityById(id: "$metadataId") {
image
name
type
description
}
}
""".trimIndent()
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request
class KodadotNftsRequest(userAddress: String) {
val query = """
query nftListByOwner(${'$'}id: String!) {
nftEntities(where: {currentOwner_eq: ${'$'}id, burned_eq: false}) {
id
image
metadata
name
price
sn
currentOwner
collection {
id
max
}
}
}
""".trimIndent()
val variables = mapOf("id" to userAddress)
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response
class KodadotCollectionResponse(
val collectionEntityById: KodadotCollectionRemote?
)
class KodadotCollectionRemote(
val name: String?,
val image: String?,
val issuer: String?
)
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response
class KodadotMetadataResponse(
val metadataEntityById: KodadotMetadataRemote?
)
class KodadotMetadataRemote(
val name: String?,
val description: String?,
val type: String?,
val image: String?
)
@@ -0,0 +1,22 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response
class KodadotNftResponse(
val nftEntities: List<KodadotNftRemote>
)
class KodadotNftRemote(
val id: String,
val image: String?,
val metadata: String?,
val name: String?,
val price: String?,
val sn: String?,
val currentOwner: String,
val collection: Collection?
) {
class Collection(
val id: String,
val max: String
)
}
@@ -0,0 +1,111 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.core_db.model.NftLocal
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.addressIn
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network.Pdc20Api
import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network.Pdc20Listing
import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network.Pdc20Request
import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
class Pdc20Provider(
private val api: Pdc20Api,
private val nftDao: NftDao,
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
) : NftProvider {
override val requireFullChainSync: Boolean = false
override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) {
val address = metaAccount.addressIn(chain) ?: return
val request = Pdc20Request(address, network = Pdc20Api.NETWORK_POLKADOT)
val nfts = api.getNfts(request)
val aggregatedListingsByToken = nfts.data.listings.groupBy { it.token.id }
.mapValues { (_, listings) ->
listings.reduce(Pdc20Listing::plus)
}
val toSave = nfts.data.userTokenBalances.map { nftRemote ->
val listing = aggregatedListingsByToken[nftRemote.token.id]
NftLocal(
identifier = nftRemote.token.id,
metaId = metaAccount.id,
chainId = chain.id,
collectionId = nftRemote.token.id,
instanceId = nftRemote.token.id,
metadata = null,
type = NftLocal.Type.PDC20,
wholeDetailsLoaded = true,
name = nftRemote.token.ticker,
label = null,
media = nftRemote.token.logo,
issuanceType = NftLocal.IssuanceType.FUNGIBLE,
// We dont know if supply or holding amount can be fractional or not so we are behaving safe
issuanceTotal = nftRemote.token.totalSupply?.toBigIntegerOrNull(),
issuanceMyAmount = nftRemote.balance.toBigIntegerOrNull(),
price = listing?.value?.let { chain.utilityAsset.planksFromAmount(it) },
pricedUnits = listing?.amount
)
}
nftDao.insertNftsDiff(NftLocal.Type.PDC20, chain.id, metaAccount.id, toSave, forceOverwrite)
}
override suspend fun nftFullSync(nft: Nft) {
// do nothing
}
override fun nftDetailsFlow(nftIdentifier: String): Flow<NftDetails> {
return flowOf {
val nftLocal = nftDao.getNft(nftIdentifier)
require(nftLocal.wholeDetailsLoaded) {
"Cannot load details of non fully-synced NFT"
}
val chain = chainRegistry.getChain(nftLocal.chainId)
val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId)
NftDetails(
identifier = nftLocal.identifier,
chain = chain,
owner = metaAccount.requireAccountIdIn(chain),
creator = null,
media = nftLocal.media,
name = nftLocal.name ?: nftLocal.instanceId!!,
description = null,
issuance = nftIssuance(nftLocal),
price = nftPrice(nftLocal),
collection = null // pdc20 token is the same as collection
)
}
}
}
private operator fun Pdc20Listing.plus(other: Pdc20Listing): Pdc20Listing {
require(this.from.address == other.from.address)
require(this.token.id == other.token.id)
return Pdc20Listing(
from = from,
token = token,
amount = amount + other.amount,
value = value + other.value
)
}
@@ -0,0 +1,15 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network
import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse
import retrofit2.http.Body
import retrofit2.http.POST
interface Pdc20Api {
companion object {
const val NETWORK_POLKADOT = "polkadot"
}
@POST("https://squid.subsquid.io/dot-ordinals/graphql")
suspend fun getNfts(@Body request: Pdc20Request): SubQueryResponse<Pdc20NftResponse>
}
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network
import java.math.BigDecimal
import java.math.BigInteger
class Pdc20NftResponse(
val userTokenBalances: List<Pdc20NftRemote>,
val listings: List<Pdc20Listing>
)
class Pdc20NftRemote(
val balance: String,
val address: PdcAddress,
val token: Token
) {
class Token(
val id: String,
val logo: String?,
val ticker: String?,
val totalSupply: String?,
val network: String
)
}
class Pdc20Listing(
val from: PdcAddress,
val token: Token,
val amount: BigInteger,
val value: BigDecimal
) {
class Token(
val id: String
)
}
class PdcAddress(val address: String)
class RmrkV1NftMetadataRemote(
val image: String,
val description: String
)
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network
class Pdc20Request(userAddress: String, network: String) {
val query = """
query {
userTokenBalances(
where: {
address: {
address_eq: "$userAddress"
}
standard_eq: "pdc-20"
token: { network_eq: "$network" }
}
) {
balance
address {
address
}
token {
id
logo
ticker
totalSupply
network
}
}
listings(
where: {
from: { address_eq: "$userAddress" }
standard_eq: "pdc-20"
token: { network_eq: "$network" }
}
) {
from {
address
}
token {
id
}
amount
value
}
}
""".trimIndent()
}
@@ -0,0 +1,58 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.core_db.model.NftLocal
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.network.RmrkV1Api
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
class RmrkV1NftProvider(
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
private val api: RmrkV1Api,
private val nftDao: NftDao
) : NftProvider {
override val requireFullChainSync: Boolean = false
override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) {
throw UnsupportedOperationException("RmrkV1 not supported")
}
override suspend fun nftFullSync(nft: Nft) {
throw UnsupportedOperationException("RmrkV1 not supported")
}
override fun nftDetailsFlow(nftIdentifier: String): Flow<NftDetails> {
return flowOf {
val nftLocal = nftDao.getNft(nftIdentifier)
require(nftLocal.wholeDetailsLoaded) {
"Cannot load details of non fully-synced NFT"
}
val chain = chainRegistry.getChain(nftLocal.chainId)
val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId)
NftDetails(
identifier = nftLocal.identifier,
chain = chain,
owner = metaAccount.accountIdIn(chain)!!,
creator = null,
media = nftLocal.media,
name = nftLocal.name!!,
description = nftLocal.label,
issuance = nftIssuance(NftLocal.IssuanceType.LIMITED, nftLocal.issuanceTotal, nftLocal.issuanceMyEdition, nftLocal.issuanceMyAmount),
price = nftPrice(nftLocal),
collection = null
)
}
}
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.network
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Url
interface RmrkV1Api {
companion object {
const val BASE_URL = "https://singular.rmrk-api.xyz/api/"
}
@GET("https://singular.rmrk.app/api/rmrk1/collection/{collectionId}")
suspend fun getCollection(@Path("collectionId") collectionId: String): List<RmrkV1CollectionRemote>
@GET
suspend fun getIpfsMetadata(@Url url: String): RmrkV1NftMetadataRemote
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.network
class RmrkV1CollectionRemote(
val max: Int,
val name: String,
val issuer: String,
val metadata: String?
)
class RmrkV1NftMetadataRemote(
val image: String,
val description: String
)
@@ -0,0 +1,119 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.core_db.model.NftLocal
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.addressIn
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice
import io.novafoundation.nova.feature_nft_impl.data.network.distributed.FileStorageAdapter.adoptFileStorageLinkToHttps
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.network.singular.SingularV2Api
import io.novafoundation.nova.runtime.ext.accountIdOf
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import kotlinx.coroutines.flow.Flow
class RmrkV2NftProvider(
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
private val singularV2Api: SingularV2Api,
private val nftDao: NftDao
) : NftProvider {
override val requireFullChainSync: Boolean = false
override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) {
val address = metaAccount.addressIn(chain) ?: return
val nfts = singularV2Api.getAccountNfts(address)
val toSave = nfts.map {
NftLocal(
identifier = localIdentifier(chain.id, it.id),
metaId = metaAccount.id,
chainId = chain.id,
collectionId = it.collectionId,
instanceId = it.id,
metadata = it.metadata?.encodeToByteArray(),
media = it.image?.adoptFileStorageLinkToHttps(),
// let name default to symbol and label to edition in case full sync wont be able to determine them from metadata
name = it.symbol,
label = it.edition,
price = it.price,
type = NftLocal.Type.RMRK2,
issuanceMyEdition = it.edition,
wholeDetailsLoaded = false,
issuanceType = NftLocal.IssuanceType.LIMITED
)
}
nftDao.insertNftsDiff(NftLocal.Type.RMRK2, chain.id, metaAccount.id, toSave, forceOverwrite)
}
override suspend fun nftFullSync(nft: Nft) {
val metadata = nft.metadataRaw?.let {
val metadataLink = it.decodeToString().adoptFileStorageLinkToHttps()
singularV2Api.getIpfsMetadata(metadataLink)
}
val collection = singularV2Api.getCollection(nft.collectionId).first()
nftDao.updateNft(nft.identifier) { local ->
// media fetched during initial sync (prerender) has more priority than one from metadata
val image = local.media ?: metadata?.image?.adoptFileStorageLinkToHttps()
local.copy(
media = image,
issuanceTotal = collection.max?.toBigInteger(),
name = metadata?.name ?: local.name,
label = metadata?.description ?: local.label,
wholeDetailsLoaded = true
)
}
}
override fun nftDetailsFlow(nftIdentifier: String): Flow<NftDetails> {
return flowOf {
val nftLocal = nftDao.getNft(nftIdentifier)
require(nftLocal.wholeDetailsLoaded) {
"Cannot load details of non fully-synced NFT"
}
val chain = chainRegistry.getChain(nftLocal.chainId)
val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId)
val collection = singularV2Api.getCollection(nftLocal.collectionId).first()
val collectionMetadata = collection.metadata?.let {
singularV2Api.getIpfsMetadata(it.adoptFileStorageLinkToHttps())
}
NftDetails(
identifier = nftLocal.identifier,
chain = chain,
owner = metaAccount.accountIdIn(chain)!!,
creator = chain.accountIdOf(collection.issuer),
media = nftLocal.media,
name = nftLocal.name!!,
description = nftLocal.label,
issuance = nftIssuance(nftLocal),
price = nftPrice(nftLocal),
collection = NftDetails.Collection(
id = nftLocal.collectionId,
name = collectionMetadata?.name,
media = collectionMetadata?.image?.adoptFileStorageLinkToHttps()
)
)
}
}
private fun localIdentifier(chainId: ChainId, remoteId: String): String {
return "$chainId-$remoteId"
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.network.singular
import io.novafoundation.nova.common.data.network.http.CacheControl
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Path
import retrofit2.http.Url
interface SingularV2Api {
companion object {
const val BASE_URL = "https://singular.rmrk-api.xyz/api/"
}
@GET("account/{accountAddress}")
@Headers(CacheControl.NO_CACHE)
suspend fun getAccountNfts(@Path("accountAddress") accountAddress: String): List<SingularV2NftRemote>
@GET("https://singular.app/api/rmrk2/collection/{collectionId}")
suspend fun getCollection(@Path("collectionId") collectionId: String): List<SingularV2CollectionRemote>
@GET
suspend fun getIpfsMetadata(@Url url: String): SingularV2CollectionMetadata
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.network.singular
import com.google.gson.annotations.SerializedName
import java.math.BigInteger
class SingularV2CollectionRemote(
val metadata: String?,
val issuer: String,
val max: Int?
)
class SingularV2NftRemote(
val id: String,
@SerializedName("forsale")
val price: BigInteger?,
val collectionId: String,
@SerializedName("sn")
val edition: String,
val image: String?, // prerender, non-null if nft is composable
val metadata: String?,
val symbol: String,
)
class SingularV2CollectionMetadata(
val name: String,
val description: String?,
@SerializedName("image", alternate = ["mediaUri"])
val image: String?
)
@@ -0,0 +1,139 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.core_db.model.NftLocal
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.addressIn
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.UniqueNetworkApi
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response.UniqueNetworkNft
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
class UniqueNetworkNftProvider(
private val uniqueNetworkApi: UniqueNetworkApi,
private val nftDao: NftDao,
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
) : NftProvider {
override val requireFullChainSync: Boolean = false
override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) {
val owner = metaAccount.addressIn(chain) ?: return
val pageSize = 100
var offset = 0
val allNfts = mutableListOf<NftLocal>()
while (true) {
val page = uniqueNetworkApi.getNftsPage(
owner = owner,
offset = offset,
limit = pageSize
)
if (page.items.isEmpty()) break
val pageNfts = page.items.map { remote ->
NftLocal(
identifier = nftIdentifier(chain, remote),
metaId = metaAccount.id,
chainId = chain.id,
collectionId = remote.collectionId.toString(),
instanceId = remote.tokenId.toString(),
metadata = null,
type = NftLocal.Type.UNIQUE_NETWORK,
wholeDetailsLoaded = false,
name = remote.name,
label = "#${remote.tokenId}",
media = remote.image,
issuanceType = NftLocal.IssuanceType.UNLIMITED,
issuanceTotal = null,
issuanceMyEdition = remote.tokenId.toString(),
issuanceMyAmount = null,
price = null,
pricedUnits = null
)
}
allNfts += pageNfts
offset += pageSize
if (allNfts.size >= page.count) break
}
nftDao.insertNftsDiff(NftLocal.Type.UNIQUE_NETWORK, chain.id, metaAccount.id, allNfts, forceOverwrite)
}
override suspend fun nftFullSync(nft: Nft) {
val collection = uniqueNetworkApi.getCollection(
collectionId = nft.collectionId.toInt(),
)
val issuanceTotal = collection.limits?.tokenLimit?.toBigInteger() ?: collection.lastTokenId?.toBigInteger()
val issuanceType = when {
collection.limits?.tokenLimit != null -> NftLocal.IssuanceType.LIMITED
else -> NftLocal.IssuanceType.UNLIMITED
}
nftDao.updateNft(nft.identifier) { local ->
local.copy(
issuanceType = issuanceType,
issuanceTotal = issuanceTotal,
wholeDetailsLoaded = true,
)
}
}
override fun nftDetailsFlow(nftIdentifier: String): Flow<NftDetails> {
return flowOf {
val nftLocal = nftDao.getNft(nftIdentifier)
require(nftLocal.wholeDetailsLoaded) {
"Cannot load details of non fully-synced NFT"
}
val chain = chainRegistry.getChain(nftLocal.chainId)
val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId)
val remoteNft = uniqueNetworkApi.getNft(
collectionId = nftLocal.collectionId.toInt(),
tokenId = nftLocal.instanceId!!.toInt()
)
val collection = uniqueNetworkApi.getCollection(
collectionId = nftLocal.collectionId.toInt(),
)
NftDetails(
identifier = nftLocal.identifier,
chain = chain,
owner = metaAccount.requireAccountIdIn(chain),
creator = null,
media = nftLocal.media,
name = nftLocal.name ?: nftLocal.instanceId!!,
description = remoteNft.description,
issuance = nftIssuance(nftLocal),
price = nftPrice(nftLocal),
collection = collection.let {
NftDetails.Collection(
id = nftLocal.collectionId,
name = it.name,
media = it.coverImage?.url
)
}
)
}
}
private fun nftIdentifier(chain: Chain, nft: UniqueNetworkNft): String {
return "unique-${chain.id}-${nft.collectionId}-${nft.tokenId}"
}
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response.UniqueNetworkCollection
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response.UniqueNetworkNft
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response.UniqueNetworkPaginatedResponse
import retrofit2.http.GET
import retrofit2.http.Query
import retrofit2.http.Path
interface UniqueNetworkApi {
companion object {
const val BASE_URL = "https://api-unique.uniquescan.io/v2/"
}
@GET("nfts")
suspend fun getNftsPage(
@Query("ownerIn") owner: String,
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("orderByTokenId") order: String = "asc"
): UniqueNetworkPaginatedResponse<UniqueNetworkNft>
@GET("nfts/{collectionId}/{tokenId}")
suspend fun getNft(
@Path("collectionId") collectionId: Int,
@Path("tokenId") tokenId: Int,
): UniqueNetworkNft
@GET("collections/{collectionId}")
suspend fun getCollection(
@Path("collectionId") collectionId: Int,
): UniqueNetworkCollection
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response
import com.google.gson.annotations.SerializedName
data class UniqueNetworkCollection(
val collectionId: Int,
val name: String?,
val coverImage: CoverImage?,
val lastTokenId: Int?,
val limits: Limits?
) {
data class CoverImage(
val url: String?
)
data class Limits(
@SerializedName("token_limit")
val tokenLimit: Int?,
)
}
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response
data class UniqueNetworkNft(
val key: String,
val collectionId: Int,
val tokenId: Int,
val image: String?,
val name: String?,
val description: String?,
)
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response
data class UniqueNetworkPaginatedResponse<T>(
val items: List<T>,
val count: Int
)
@@ -0,0 +1,194 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.data.network.runtime.binding.cast
import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct
import io.novafoundation.nova.common.data.network.runtime.binding.getTyped
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.uniques
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.core_db.model.NftLocal
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance
import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice
import io.novafoundation.nova.feature_nft_impl.data.network.distributed.FileStorageAdapter.adoptFileStorageLinkToHttps
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.network.IpfsApi
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novafoundation.nova.runtime.storage.source.multi.MultiQueryBuilder
import io.novafoundation.nova.runtime.storage.source.multi.singleValueOf
import io.novafoundation.nova.runtime.storage.source.query.multi
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct
import io.novasama.substrate_sdk_android.runtime.metadata.storage
import kotlinx.coroutines.flow.Flow
import java.math.BigInteger
class UniquesNftProvider(
private val remoteStorage: StorageDataSource,
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
private val nftDao: NftDao,
private val ipfsApi: IpfsApi,
) : NftProvider {
override val requireFullChainSync: Boolean = true
override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) {
val accountId = metaAccount.accountIdIn(chain) ?: return
val newNfts = remoteStorage.query(chain.id) {
val classesWithInstances = runtime.metadata.uniques().storage("Account").keys(accountId)
.map { (_: AccountId, collection: BigInteger, instance: BigInteger) ->
listOf(collection, instance)
}
val classesIds = classesWithInstances.map { (collection, _) -> collection }.distinct()
val classMetadataDescriptor: MultiQueryBuilder.Descriptor<BigInteger, ByteArray?>
val totalIssuanceDescriptor: MultiQueryBuilder.Descriptor<BigInteger, BigInteger>
val instanceMetadataDescriptor: MultiQueryBuilder.Descriptor<Pair<BigInteger, BigInteger>, ByteArray?>
val multiQueryResults = multi {
classMetadataDescriptor = runtime.metadata.uniques().storage("ClassMetadataOf").querySingleArgKeys(
keysArgs = classesIds,
keyExtractor = { (classId: BigInteger) -> classId },
binding = ::bindMetadata
)
instanceMetadataDescriptor = runtime.metadata.uniques().storage("InstanceMetadataOf").queryKeys(
keysArgs = classesWithInstances,
keyExtractor = { (classId: BigInteger, instance: BigInteger) -> classId to instance },
binding = ::bindMetadata
)
totalIssuanceDescriptor = runtime.metadata.uniques().storage("Class").querySingleArgKeys(
keysArgs = classesIds,
keyExtractor = { (classId: BigInteger) -> classId },
binding = { bindNumber(it.castToStruct()["items"]) }
)
}
val classMetadatas = multiQueryResults[classMetadataDescriptor]
val totalIssuances = multiQueryResults[totalIssuanceDescriptor]
val instancesMetadatas = multiQueryResults[instanceMetadataDescriptor]
classesWithInstances.map { (collectionId, instanceId) ->
val instanceKey = collectionId to instanceId
val metadata = instancesMetadatas[instanceKey] ?: classMetadatas[collectionId]
NftLocal(
identifier = identifier(chain.id, collectionId, instanceId),
metaId = metaAccount.id,
chainId = chain.id,
collectionId = collectionId.toString(),
instanceId = instanceId.toString(),
metadata = metadata,
type = NftLocal.Type.UNIQUES,
issuanceTotal = totalIssuances.getValue(collectionId),
issuanceMyEdition = instanceId.toString(),
issuanceType = NftLocal.IssuanceType.LIMITED,
price = null,
// to load at full sync
name = null,
label = null,
media = null,
wholeDetailsLoaded = false
)
}
}
nftDao.insertNftsDiff(NftLocal.Type.UNIQUES, chain.id, metaAccount.id, newNfts, forceOverwrite)
}
override suspend fun nftFullSync(nft: Nft) {
if (nft.metadataRaw == null) {
nftDao.markFullSynced(nft.identifier)
return
}
val metadataLink = nft.metadataRaw!!.decodeToString().adoptFileStorageLinkToHttps()
val metadata = ipfsApi.getIpfsMetadata(metadataLink)
nftDao.updateNft(nft.identifier) { local ->
local.copy(
name = metadata.name!!,
media = metadata.image?.adoptFileStorageLinkToHttps(),
label = metadata.description,
wholeDetailsLoaded = true
)
}
}
override fun nftDetailsFlow(nftIdentifier: String): Flow<NftDetails> {
return flowOf {
val nftLocal = nftDao.getNft(nftIdentifier)
require(nftLocal.wholeDetailsLoaded) {
"Cannot load details of non fully-synced NFT"
}
val chain = chainRegistry.getChain(nftLocal.chainId)
val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId)
val classId = nftLocal.collectionId.toBigInteger()
remoteStorage.query(chain.id) {
var classMetadataDescriptor: MultiQueryBuilder.Descriptor<*, ByteArray?>
var classDescriptor: MultiQueryBuilder.Descriptor<*, AccountId>
val queryResults = multi {
classMetadataDescriptor = runtime.metadata.uniques().storage("ClassMetadataOf").queryKey(classId, binding = ::bindMetadata)
classDescriptor = runtime.metadata.uniques().storage("Class").queryKey(classId, binding = ::bindIssuer)
}
val classMetadataPointer = queryResults.singleValueOf(classMetadataDescriptor)
val collection = if (classMetadataPointer == null) {
NftDetails.Collection(nftLocal.collectionId)
} else {
val url = classMetadataPointer.decodeToString().adoptFileStorageLinkToHttps()
val classMetadata = ipfsApi.getIpfsMetadata(url)
NftDetails.Collection(
id = nftLocal.collectionId,
name = classMetadata.name,
media = classMetadata.image?.adoptFileStorageLinkToHttps()
)
}
val classIssuer = queryResults.singleValueOf(classDescriptor)
NftDetails(
identifier = nftLocal.identifier,
chain = chain,
owner = metaAccount.requireAccountIdIn(chain),
creator = classIssuer,
media = nftLocal.media,
name = nftLocal.name ?: nftLocal.instanceId!!,
description = nftLocal.label,
issuance = nftIssuance(nftLocal),
price = nftPrice(nftLocal),
collection = collection
)
}
}
}
private fun bindIssuer(dynamic: Any?): AccountId = bindAccountId(dynamic.castToStruct()["issuer"])
private fun bindMetadata(dynamic: Any?): ByteArray? = dynamic?.cast<Struct.Instance>()?.getTyped("data")
private fun identifier(chainId: ChainId, collectionId: BigInteger, instanceId: BigInteger): String {
return "$chainId-$collectionId-$instanceId"
}
}
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.network
import retrofit2.http.GET
import retrofit2.http.Url
interface IpfsApi {
@GET
suspend fun getIpfsMetadata(@Url url: String): UniquesMetadata
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.network
class UniquesMetadata(
val name: String?,
val image: String?,
val description: String?
)
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_nft_impl.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_nft_api.NftFeatureApi
import io.novafoundation.nova.feature_nft_impl.NftRouter
import io.novafoundation.nova.feature_nft_impl.presentation.nft.details.di.NftDetailsComponent
import io.novafoundation.nova.feature_nft_impl.presentation.nft.list.di.NftListComponent
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
NftFeatureDependencies::class
],
modules = [
NftFeatureModule::class
]
)
@FeatureScope
interface NftFeatureComponent : NftFeatureApi {
fun nftListComponentFactory(): NftListComponent.Factory
fun nftDetailsComponentFactory(): NftDetailsComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance router: NftRouter,
deps: NftFeatureDependencies
): NftFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
DbApi::class,
AccountFeatureApi::class,
WalletFeatureApi::class,
RuntimeApi::class
]
)
interface NftFeatureDependenciesComponent : NftFeatureDependencies
}
@@ -0,0 +1,59 @@
package io.novafoundation.nova.feature_nft_impl.di
import coil.ImageLoader
import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.data.network.HttpExceptionHandler
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import javax.inject.Named
interface NftFeatureDependencies {
val amountFormatter: AmountFormatter
fun accountRepository(): AccountRepository
fun resourceManager(): ResourceManager
fun selectedAccountUseCase(): SelectedAccountUseCase
fun addressIconGenerator(): AddressIconGenerator
fun gson(): Gson
fun chainRegistry(): ChainRegistry
fun imageLoader(): ImageLoader
fun externalAccountActions(): ExternalActions.Presentation
fun addressDisplayUseCase(): AddressDisplayUseCase
fun feeLoaderMixinFactory(): FeeLoaderMixin.Factory
fun extrinsicService(): ExtrinsicService
fun tokenRepository(): TokenRepository
fun apiCreator(): NetworkApiCreator
fun nftDao(): NftDao
@Named(REMOTE_STORAGE_SOURCE)
fun remoteStorageSource(): StorageDataSource
fun exceptionHandler(): HttpExceptionHandler
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_nft_impl.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_nft_impl.NftRouter
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class NftFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: NftRouter,
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val dApp = DaggerNftFeatureComponent_NftFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.walletFeatureApi(getFeature(WalletFeatureApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.build()
return DaggerNftFeatureComponent.factory()
.create(router, dApp)
}
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.feature_nft_impl.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.HttpExceptionHandler
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_nft_impl.data.repository.NftRepositoryImpl
import io.novafoundation.nova.feature_nft_impl.data.source.JobOrchestrator
import io.novafoundation.nova.feature_nft_impl.data.source.NftProvidersRegistry
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.KodadotProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.Pdc20Provider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.RmrkV1NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.RmrkV2NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.UniqueNetworkNftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.UniquesNftProvider
import io.novafoundation.nova.feature_nft_impl.di.modules.KodadotModule
import io.novafoundation.nova.feature_nft_impl.di.modules.Pdc20Module
import io.novafoundation.nova.feature_nft_impl.di.modules.RmrkV1Module
import io.novafoundation.nova.feature_nft_impl.di.modules.RmrkV2Module
import io.novafoundation.nova.feature_nft_impl.di.modules.UniquesModule
import io.novafoundation.nova.feature_nft_impl.di.modules.UniqueNetworkModule
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(
includes = [
UniquesModule::class,
RmrkV1Module::class,
RmrkV2Module::class,
Pdc20Module::class,
KodadotModule::class,
UniqueNetworkModule::class
]
)
class NftFeatureModule {
@Provides
@FeatureScope
fun provideJobOrchestrator() = JobOrchestrator()
@Provides
@FeatureScope
fun provideNftProviderRegistry(
uniquesNftProvider: UniquesNftProvider,
rmrkV1NftProvider: RmrkV1NftProvider,
rmrkV2NftProvider: RmrkV2NftProvider,
pdc20Provider: Pdc20Provider,
kodadotProvider: KodadotProvider,
uniqueNetworkProvider: UniqueNetworkNftProvider
) = NftProvidersRegistry(uniquesNftProvider, rmrkV1NftProvider, rmrkV2NftProvider, pdc20Provider, kodadotProvider, uniqueNetworkProvider)
@Provides
@FeatureScope
fun provideNftRepository(
nftProvidersRegistry: NftProvidersRegistry,
chainRegistry: ChainRegistry,
jobOrchestrator: JobOrchestrator,
nftDao: NftDao,
httpExceptionHandler: HttpExceptionHandler,
): NftRepository = NftRepositoryImpl(
nftProvidersRegistry = nftProvidersRegistry,
chainRegistry = chainRegistry,
jobOrchestrator = jobOrchestrator,
nftDao = nftDao,
exceptionHandler = httpExceptionHandler
)
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_nft_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.KodadotProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.KodadotApi
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class KodadotModule {
@Provides
@FeatureScope
fun provideApi(networkApiCreator: NetworkApiCreator): KodadotApi {
return networkApiCreator.create(KodadotApi::class.java)
}
@Provides
@FeatureScope
fun provideKodadotProvider(
api: KodadotApi,
nftDao: NftDao,
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
) = KodadotProvider(
api = api,
nftDao = nftDao,
accountRepository = accountRepository,
chainRegistry = chainRegistry
)
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_nft_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.Pdc20Provider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network.Pdc20Api
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class Pdc20Module {
@Provides
@FeatureScope
fun provideApi(networkApiCreator: NetworkApiCreator): Pdc20Api {
return networkApiCreator.create(Pdc20Api::class.java)
}
@Provides
@FeatureScope
fun provideNftProvider(
api: Pdc20Api,
nftDao: NftDao,
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
) = Pdc20Provider(
api = api,
nftDao = nftDao,
accountRepository = accountRepository,
chainRegistry = chainRegistry
)
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_nft_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.RmrkV1NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.network.RmrkV1Api
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class RmrkV1Module {
@Provides
@FeatureScope
fun provideApi(networkApiCreator: NetworkApiCreator): RmrkV1Api {
return networkApiCreator.create(RmrkV1Api::class.java, RmrkV1Api.BASE_URL)
}
@Provides
@FeatureScope
fun provideNftProvider(
chainRegistry: ChainRegistry,
accountRepository: AccountRepository,
api: RmrkV1Api,
nftDao: NftDao
) = RmrkV1NftProvider(chainRegistry, accountRepository, api, nftDao)
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_nft_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.RmrkV2NftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.network.singular.SingularV2Api
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class RmrkV2Module {
@Provides
@FeatureScope
fun provideSingularApi(networkApiCreator: NetworkApiCreator): SingularV2Api {
return networkApiCreator.create(SingularV2Api::class.java, SingularV2Api.BASE_URL)
}
@Provides
@FeatureScope
fun provideNftProvider(
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
singularV2Api: SingularV2Api,
nftDao: NftDao
) = RmrkV2NftProvider(chainRegistry, accountRepository, singularV2Api, nftDao)
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_nft_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.UniqueNetworkNftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.UniqueNetworkApi
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class UniqueNetworkModule {
@Provides
@FeatureScope
fun provideApi(networkApiCreator: NetworkApiCreator): UniqueNetworkApi {
return networkApiCreator.create(UniqueNetworkApi::class.java, UniqueNetworkApi.BASE_URL)
}
@Provides
@FeatureScope
fun provideUniqueNetworkNftProvider(
uniqueNetworkApi: UniqueNetworkApi,
nftDao: NftDao,
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
) = UniqueNetworkNftProvider(
uniqueNetworkApi = uniqueNetworkApi,
nftDao = nftDao,
accountRepository = accountRepository,
chainRegistry = chainRegistry
)
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_nft_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.NftDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.UniquesNftProvider
import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.network.IpfsApi
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import javax.inject.Named
@Module
class UniquesModule {
@Provides
@FeatureScope
fun provideIpfsApi(networkApiCreator: NetworkApiCreator) = networkApiCreator.create(IpfsApi::class.java)
@Provides
@FeatureScope
fun provideUniquesNftProvider(
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
nftDao: NftDao,
ipfsApi: IpfsApi,
) = UniquesNftProvider(remoteStorageSource, accountRepository, chainRegistry, nftDao, ipfsApi)
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_nft_impl.domain.nft.details
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.runtime.ext.utilityAsset
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
class NftDetailsInteractor(
private val nftRepository: NftRepository,
private val tokenRepository: TokenRepository
) {
fun nftDetailsFlow(nftIdentifier: String): Flow<PricedNftDetails> {
return nftRepository.nftDetails(nftIdentifier).flatMapLatest { nftDetails ->
tokenRepository.observeToken(nftDetails.chain.utilityAsset).map { token ->
PricedNftDetails(
nftDetails = nftDetails,
priceToken = token
)
}
}
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_nft_impl.domain.nft.details
import io.novafoundation.nova.feature_nft_api.data.model.NftDetails
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
class PricedNftDetails(
val nftDetails: NftDetails,
val priceToken: Token
)
@@ -0,0 +1,47 @@
package io.novafoundation.nova.feature_nft_impl.domain.nft.list
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.ext.utilityAsset
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
class NftListInteractor(
private val accountRepository: AccountRepository,
private val tokenRepository: TokenRepository,
private val nftRepository: NftRepository,
) {
fun userNftsFlow(): Flow<List<PricedNft>> {
return accountRepository.selectedMetaAccountFlow()
.flatMapLatest(nftRepository::allNftFlow)
.map { nfts -> nfts.sortedBy { it.identifier } }
.flatMapLatest { nfts ->
val allUtilityAssets = nfts.map { it.chain.utilityAsset }.distinct()
tokenRepository.observeTokens(allUtilityAssets).mapLatest { tokensByUtilityAsset ->
nfts.map { nft ->
PricedNft(
nft = nft,
nftPriceToken = tokensByUtilityAsset[nft.chain.utilityAsset.fullId]
)
}
}
}
}
suspend fun syncNftsList() = withContext(Dispatchers.Default) {
nftRepository.initialNftSync(accountRepository.getSelectedMetaAccount(), forceOverwrite = true)
}
suspend fun fullSyncNft(nft: Nft) {
nftRepository.fullNftSync(nft)
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_nft_impl.domain.nft.list
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
class PricedNft(
val nft: Nft,
val nftPriceToken: Token?
)
@@ -0,0 +1,56 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.common
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.formatting.format
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_impl.R
import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model.NftPriceModel
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
fun ResourceManager.formatIssuance(issuance: Nft.Issuance): String {
return when (issuance) {
is Nft.Issuance.Unlimited -> getString(R.string.nft_issuance_unlimited)
is Nft.Issuance.Limited -> {
getString(
R.string.nft_issuance_limited_format,
issuance.edition.format(),
issuance.max.format()
)
}
is Nft.Issuance.Fungible -> {
getString(
R.string.nft_issuance_fungible_format,
issuance.myAmount.format(),
issuance.totalSupply.format()
)
}
}
}
fun ResourceManager.formatNftPrice(amountFormatter: AmountFormatter, price: Nft.Price?, priceToken: Token?): NftPriceModel? {
if (price == null || priceToken == null) return null
return when (price) {
is Nft.Price.Fungible -> {
val units = price.units.format()
val amountModel = amountFormatter.formatAmountToAmountModel(price.totalPrice, priceToken)
NftPriceModel(
amountInfo = getString(R.string.nft_fungile_price, units, amountModel.token),
fiat = amountModel.fiat
)
}
is Nft.Price.NonFungible -> {
val amountModel = amountFormatter.formatAmountToAmountModel(price.nftPrice, priceToken)
NftPriceModel(
amountInfo = amountModel.token,
fiat = amountModel.fiat
)
}
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.common
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import io.novafoundation.nova.common.utils.WithContextExtensions
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.utils.updatePadding
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.feature_nft_impl.R
class NftIssuanceView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr), WithContextExtensions {
override val providedContext: Context = context
init {
setTextAppearance(R.style.TextAppearance_NovaFoundation_SemiBold_Caps2)
setTextColorRes(R.color.chip_text)
updatePadding(top = 1.5f.dp, bottom = 1.5f.dp, start = 6.dp, end = 6.dp)
background = context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 4)
}
}
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model
class NftPriceModel(
val amountInfo: CharSequence,
val fiat: CharSequence?
)
@@ -0,0 +1,119 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.details
import android.text.TextUtils
import android.view.View
import androidx.core.os.bundleOf
import coil.ImageLoader
import coil.load
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.letOrHide
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.makeVisible
import io.novafoundation.nova.common.utils.setTextOrHide
import io.novafoundation.nova.common.view.dialog.errorDialog
import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions
import io.novafoundation.nova.feature_account_api.view.showAddress
import io.novafoundation.nova.feature_account_api.view.showChain
import io.novafoundation.nova.feature_nft_api.NftFeatureApi
import io.novafoundation.nova.feature_nft_impl.R
import io.novafoundation.nova.feature_nft_impl.databinding.FragmentNftDetailsBinding
import io.novafoundation.nova.feature_nft_impl.di.NftFeatureComponent
import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model.NftPriceModel
import io.novafoundation.nova.feature_wallet_api.presentation.view.PriceSectionView
import javax.inject.Inject
class NftDetailsFragment : BaseFragment<NftDetailsViewModel, FragmentNftDetailsBinding>() {
companion object {
private const val PAYLOAD = "NftDetailsFragment.PAYLOAD"
fun getBundle(nftId: String) = bundleOf(PAYLOAD to nftId)
}
override fun createBinding() = FragmentNftDetailsBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val contentViews by lazy(LazyThreadSafetyMode.NONE) {
listOf(
binder.nftDetailsMedia,
binder.nftDetailsTitle,
binder.nftDetailsDescription,
binder.nftDetailsIssuance,
binder.nftDetailsPrice,
binder.nftDetailsTable
)
}
override fun initViews() {
binder.nftDetailsToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.nftDetailsOnwer.setOnClickListener { viewModel.ownerClicked() }
binder.nftDetailsCreator.setOnClickListener { viewModel.creatorClicked() }
binder.nftDetailsCollection.valuePrimary.ellipsize = TextUtils.TruncateAt.END
binder.nftDetailsProgress.makeVisible()
contentViews.forEach(View::makeGone)
}
override fun inject() {
FeatureUtils.getFeature<NftFeatureComponent>(this, NftFeatureApi::class.java)
.nftDetailsComponentFactory()
.create(this, argument(PAYLOAD))
.inject(this)
}
override fun subscribe(viewModel: NftDetailsViewModel) {
setupExternalActions(viewModel)
viewModel.nftDetailsUi.observe {
binder.nftDetailsProgress.makeGone()
contentViews.forEach(View::makeVisible)
binder.nftDetailsMedia.load(it.media, imageLoader) {
placeholder(R.drawable.nft_media_progress)
error(R.drawable.nft_media_progress)
}
binder.nftDetailsTitle.text = it.name
binder.nftDetailsDescription.setTextOrHide(it.description)
binder.nftDetailsIssuance.text = it.issuance
binder.nftDetailsPrice.setPriceOrHide(it.price)
if (it.collection != null) {
binder.nftDetailsCollection.makeVisible()
binder.nftDetailsCollection.loadImage(it.collection.media)
binder.nftDetailsCollection.showValue(it.collection.name)
} else {
binder.nftDetailsCollection.makeGone()
}
binder.nftDetailsOnwer.showAddress(it.owner)
if (it.creator != null) {
binder.nftDetailsCreator.makeVisible()
binder.nftDetailsCreator.showAddress(it.creator)
} else {
binder.nftDetailsCreator.makeGone()
}
binder.nftDetailsChain.showChain(it.network)
}
viewModel.exitingErrorLiveData.observeEvent {
errorDialog(requireContext(), onConfirm = viewModel::backClicked) {
setMessage(it)
}
}
}
private fun PriceSectionView.setPriceOrHide(maybePrice: NftPriceModel?) = letOrHide(maybePrice) { price ->
setPrice(price.amountInfo, price.fiat)
}
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.details
import io.novafoundation.nova.common.address.AddressModel
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model.NftPriceModel
class NftDetailsModel(
val media: String?,
val name: String,
val issuance: String,
val description: String?,
val price: NftPriceModel?,
val collection: Collection?,
val owner: AddressModel,
val creator: AddressModel?,
val network: ChainUi
) {
class Collection(
val name: String,
val media: String?
)
}
@@ -0,0 +1,107 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.details
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.event
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions
import io.novafoundation.nova.feature_nft_impl.NftRouter
import io.novafoundation.nova.feature_nft_impl.domain.nft.details.NftDetailsInteractor
import io.novafoundation.nova.feature_nft_impl.domain.nft.details.PricedNftDetails
import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.formatIssuance
import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.formatNftPrice
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class NftDetailsViewModel(
private val router: NftRouter,
private val resourceManager: ResourceManager,
private val interactor: NftDetailsInteractor,
private val nftIdentifier: String,
private val externalActionsDelegate: ExternalActions.Presentation,
private val addressIconGenerator: AddressIconGenerator,
private val addressDisplayUseCase: AddressDisplayUseCase,
private val amountFormatter: AmountFormatter
) : BaseViewModel(), ExternalActions by externalActionsDelegate {
private val _exitingErrorLiveData = MutableLiveData<Event<String>>()
val exitingErrorLiveData: LiveData<Event<String>> = _exitingErrorLiveData
private val nftDetailsFlow = interactor.nftDetailsFlow(nftIdentifier)
.inBackground()
.catch { showExitingError(it) }
.share()
val nftDetailsUi = nftDetailsFlow
.map(::mapNftDetailsToUi)
.inBackground()
.share()
fun ownerClicked() = launch {
val pricedNftDetails = nftDetailsFlow.first()
with(pricedNftDetails.nftDetails) {
externalActionsDelegate.showAddressActions(owner, chain)
}
}
fun creatorClicked() = launch {
val pricedNftDetails = nftDetailsFlow.first()
with(pricedNftDetails.nftDetails) {
externalActionsDelegate.showAddressActions(creator!!, chain)
}
}
private fun showExitingError(exception: Throwable) {
_exitingErrorLiveData.value = exception.message.orEmpty().event()
}
private suspend fun mapNftDetailsToUi(pricedNftDetails: PricedNftDetails): NftDetailsModel {
val nftDetails = pricedNftDetails.nftDetails
return NftDetailsModel(
media = nftDetails.media,
name = nftDetails.name,
issuance = resourceManager.formatIssuance(nftDetails.issuance),
description = nftDetails.description,
price = resourceManager.formatNftPrice(amountFormatter, pricedNftDetails.nftDetails.price, pricedNftDetails.priceToken),
collection = nftDetails.collection?.let {
NftDetailsModel.Collection(
name = it.name ?: it.id,
media = it.media,
)
},
owner = createAddressModel(nftDetails.owner, nftDetails.chain),
creator = nftDetails.creator?.let {
createAddressModel(it, nftDetails.chain)
},
network = mapChainToUi(nftDetails.chain)
)
}
private suspend fun createAddressModel(accountId: AccountId, chain: Chain) = addressIconGenerator.createAddressModel(
chain = chain,
accountId = accountId,
sizeInDp = AddressIconGenerator.SIZE_MEDIUM,
addressDisplayUseCase = addressDisplayUseCase,
background = AddressIconGenerator.BACKGROUND_TRANSPARENT
)
fun backClicked() {
router.back()
}
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.details.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_nft_impl.NftRouter
import io.novafoundation.nova.feature_nft_impl.domain.nft.details.NftDetailsInteractor
import io.novafoundation.nova.feature_nft_impl.presentation.nft.details.NftDetailsViewModel
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
@Module(includes = [ViewModelModule::class])
class NfDetailsModule {
@Provides
@ScreenScope
fun provideInteractor(
nftRepository: NftRepository,
tokenRepository: TokenRepository
) = NftDetailsInteractor(
tokenRepository = tokenRepository,
nftRepository = nftRepository
)
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NftDetailsViewModel {
return ViewModelProvider(fragment, factory).get(NftDetailsViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(NftDetailsViewModel::class)
fun provideViewModel(
router: NftRouter,
resourceManager: ResourceManager,
interactor: NftDetailsInteractor,
nftIdentifier: String,
accountExternalActions: ExternalActions.Presentation,
addressIconGenerator: AddressIconGenerator,
addressDisplayUseCase: AddressDisplayUseCase,
amountFormatter: AmountFormatter
): ViewModel {
return NftDetailsViewModel(
router = router,
resourceManager = resourceManager,
interactor = interactor,
nftIdentifier = nftIdentifier,
externalActionsDelegate = accountExternalActions,
addressIconGenerator = addressIconGenerator,
addressDisplayUseCase = addressDisplayUseCase,
amountFormatter = amountFormatter
)
}
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.details.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_nft_impl.presentation.nft.details.NftDetailsFragment
@Subcomponent(
modules = [
NfDetailsModule::class
]
)
@ScreenScope
interface NftDetailsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance nftId: String
): NftDetailsComponent
}
fun inject(fragment: NftDetailsFragment)
}
@@ -0,0 +1,134 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.list
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import coil.clear
import coil.load
import coil.transform.RoundedCornersTransformation
import io.novafoundation.nova.common.presentation.LoadingState
import io.novafoundation.nova.common.utils.dpF
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.makeVisible
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.common.view.shape.addRipple
import io.novafoundation.nova.common.view.shape.getRippleMask
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.feature_nft_impl.R
import io.novafoundation.nova.feature_nft_impl.databinding.ItemNftBinding
import kotlinx.android.extensions.LayoutContainer
class NftAdapter(
private val imageLoader: ImageLoader,
private val handler: Handler
) : ListAdapter<NftListItem, NftHolder>(DiffCallback) {
interface Handler {
fun itemClicked(item: NftListItem)
fun loadableItemShown(item: NftListItem)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NftHolder {
return NftHolder(ItemNftBinding.inflate(parent.inflater(), parent, false), imageLoader, handler)
}
override fun onBindViewHolder(holder: NftHolder, position: Int) {
holder.bind(getItem(position))
}
override fun onViewRecycled(holder: NftHolder) {
holder.unbind()
}
}
private object DiffCallback : DiffUtil.ItemCallback<NftListItem>() {
override fun areItemsTheSame(oldItem: NftListItem, newItem: NftListItem): Boolean {
return oldItem.identifier == newItem.identifier
}
override fun areContentsTheSame(oldItem: NftListItem, newItem: NftListItem): Boolean {
return oldItem == newItem
}
}
class NftHolder(
private val binder: ItemNftBinding,
private val imageLoader: ImageLoader,
private val itemHandler: NftAdapter.Handler
) : RecyclerView.ViewHolder(binder.root), LayoutContainer {
override val containerView = binder.root
init {
with(containerView) {
binder.itemNftContent.background = with(context) {
addRipple(getRoundedCornerDrawable(R.color.block_background, cornerSizeInDp = 12), mask = getRippleMask(cornerSizeDp = 12))
}
}
}
fun unbind() {
binder.itemNftMedia.clear()
}
fun bind(item: NftListItem) = with(binder) {
when (val content = item.content) {
is LoadingState.Loading -> {
itemNftShimmer.makeVisible()
itemNftShimmer.startShimmer()
itemNftContent.makeGone()
itemHandler.loadableItemShown(item)
}
is LoadingState.Loaded -> {
itemNftShimmer.makeGone()
itemNftShimmer.stopShimmer()
itemNftContent.makeVisible()
itemNftMedia.load(content.data.media, imageLoader) {
transformations(RoundedCornersTransformation(8.dpF(containerView.context)))
placeholder(R.drawable.nft_media_progress)
error(R.drawable.nft_media_error)
fallback(R.drawable.nft_media_error)
listener(
onError = { _, _ ->
// so that placeholder would be able to change aspect ratio and fill ImageView entirely
itemNftMedia.scaleType = ImageView.ScaleType.FIT_XY
},
onSuccess = { _, _ ->
// set default scale type back
itemNftMedia.scaleType = ImageView.ScaleType.FIT_CENTER
}
)
}
itemNftIssuance.text = content.data.issuance
itemNftTitle.text = content.data.title
setPrice(content)
}
}
containerView.setOnClickListener { itemHandler.itemClicked(item) }
}
private fun setPrice(content: LoadingState.Loaded<NftListItem.Content>) {
val price = content.data.price
binder.itemNftPriceToken.setVisible(price != null)
binder.itemNftPriceFiat.setVisible(price != null)
binder.itemNftPricePlaceholder.setVisible(price == null)
if (price != null) {
binder.itemNftPriceToken.text = price.amountInfo
binder.itemNftPriceFiat.text = price.fiat
}
}
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.list
import android.view.View
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.submitListPreservingViewPoint
import io.novafoundation.nova.feature_nft_api.NftFeatureApi
import io.novafoundation.nova.feature_nft_impl.databinding.FragmentNftListBinding
import io.novafoundation.nova.feature_nft_impl.di.NftFeatureComponent
import javax.inject.Inject
class NftListFragment : BaseFragment<NftListViewModel, FragmentNftListBinding>(), NftAdapter.Handler {
override fun createBinding() = FragmentNftListBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val adapter by lazy(LazyThreadSafetyMode.NONE) { NftAdapter(imageLoader, this) }
override fun applyInsets(rootView: View) {
binder.nftListToolbar.applyStatusBarInsets()
binder.nftListNfts.applyNavigationBarInsets()
}
override fun initViews() {
binder.nftListBack.setOnClickListener { viewModel.backClicked() }
binder.nftListNfts.adapter = adapter
binder.nftListNfts.itemAnimator = null
binder.nftListRefresh.setOnRefreshListener { viewModel.syncNfts() }
}
override fun inject() {
FeatureUtils.getFeature<NftFeatureComponent>(this, NftFeatureApi::class.java)
.nftListComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: NftListViewModel) {
viewModel.nftListItemsFlow.observe {
adapter.submitListPreservingViewPoint(it, binder.nftListNfts)
}
viewModel.hideRefreshEvent.observeEvent {
binder.nftListRefresh.isRefreshing = false
}
viewModel.nftCountFlow.observe(binder.nftListCounter::setText)
}
override fun itemClicked(item: NftListItem) {
viewModel.nftClicked(item)
}
override fun loadableItemShown(item: NftListItem) {
viewModel.loadableNftShown(item)
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.list
import io.novafoundation.nova.common.presentation.LoadingState
import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model.NftPriceModel
data class NftListItem(
val content: LoadingState<Content>,
val identifier: String,
) {
data class Content(
val issuance: String,
val title: String,
val price: NftPriceModel?,
val media: String?,
)
}
@@ -0,0 +1,96 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.presentation.LoadingState
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.formatting.format
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_impl.NftRouter
import io.novafoundation.nova.feature_nft_impl.domain.nft.list.NftListInteractor
import io.novafoundation.nova.feature_nft_impl.domain.nft.list.PricedNft
import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.formatIssuance
import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.formatNftPrice
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class NftListViewModel(
private val router: NftRouter,
private val resourceManager: ResourceManager,
private val interactor: NftListInteractor,
private val amountFormatter: AmountFormatter
) : BaseViewModel() {
private val nftsFlow = interactor.userNftsFlow()
.inBackground()
.share()
val nftCountFlow = nftsFlow.map { it.size.format() }
.share()
val nftListItemsFlow = nftsFlow.mapList(::mapNftToListItem)
.inBackground()
.share()
private val _hideRefreshEvent = MutableLiveData<Event<Unit>>()
val hideRefreshEvent: LiveData<Event<Unit>> = _hideRefreshEvent
fun syncNfts() {
viewModelScope.launch {
interactor.syncNftsList()
_hideRefreshEvent.value = Event(Unit)
}
}
fun nftClicked(nftListItem: NftListItem) = launch {
if (nftListItem.content is LoadingState.Loaded) {
router.openNftDetails(nftListItem.identifier)
}
}
fun loadableNftShown(nftListItem: NftListItem) = launch(Dispatchers.Default) {
val pricedNft = nftsFlow.first().firstOrNull { it.nft.identifier == nftListItem.identifier }
?: return@launch
interactor.fullSyncNft(pricedNft.nft)
}
private fun mapNftToListItem(pricedNft: PricedNft): NftListItem {
val content = when (val details = pricedNft.nft.details) {
Nft.Details.Loadable -> LoadingState.Loading()
is Nft.Details.Loaded -> {
val issuanceFormatted = resourceManager.formatIssuance(details.issuance)
val price = resourceManager.formatNftPrice(amountFormatter, details.price, pricedNft.nftPriceToken)
LoadingState.Loaded(
NftListItem.Content(
issuance = issuanceFormatted,
title = details.name ?: pricedNft.nft.instanceId ?: pricedNft.nft.collectionId,
price = price,
media = details.media
)
)
}
}
return NftListItem(
identifier = pricedNft.nft.identifier,
content = content
)
}
fun backClicked() {
router.back()
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.list.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_nft_impl.presentation.nft.list.NftListFragment
@Subcomponent(
modules = [
NftListModule::class
]
)
@ScreenScope
interface NftListComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): NftListComponent
}
fun inject(fragment: NftListFragment)
}
@@ -0,0 +1,57 @@
package io.novafoundation.nova.feature_nft_impl.presentation.nft.list.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_nft_impl.NftRouter
import io.novafoundation.nova.feature_nft_impl.domain.nft.list.NftListInteractor
import io.novafoundation.nova.feature_nft_impl.presentation.nft.list.NftListViewModel
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
@Module(includes = [ViewModelModule::class])
class NftListModule {
@Provides
@ScreenScope
fun provideInteractor(
accountRepository: AccountRepository,
nftRepository: NftRepository,
tokenRepository: TokenRepository
) = NftListInteractor(
accountRepository = accountRepository,
tokenRepository = tokenRepository,
nftRepository = nftRepository
)
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NftListViewModel {
return ViewModelProvider(fragment, factory).get(NftListViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(NftListViewModel::class)
fun provideViewModel(
router: NftRouter,
resourceManager: ResourceManager,
interactor: NftListInteractor,
amountFormatter: AmountFormatter
): ViewModel {
return NftListViewModel(
router = router,
resourceManager = resourceManager,
interactor = interactor,
amountFormatter = amountFormatter
)
}
}
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="150dp"
android:height="150dp"
android:viewportWidth="154"
android:viewportHeight="154">
<path
android:fillColor="#7A08090E"
android:pathData="M8,0L146,0A8,8 0,0 1,154 8L154,146A8,8 0,0 1,146 154L8,154A8,8 0,0 1,0 146L0,8A8,8 0,0 1,8 0z" />
<path
android:fillColor="#29FFFFFF"
android:fillType="evenOdd"
android:pathData="M64.75,69C64.75,67.757 65.757,66.75 67,66.75H87C88.243,66.75 89.25,67.757 89.25,69V82.398L85.883,79.591L85.883,79.59C85.355,79.151 84.682,78.926 83.997,78.957C83.312,78.989 82.663,79.276 82.178,79.762L82.178,79.762L80.501,81.438L74.864,75.801L74.864,75.801C74.368,75.306 73.702,75.018 73.001,74.997C72.3,74.977 71.618,75.224 71.094,75.69L71.093,75.69L64.75,81.33V69ZM79.44,82.499L73.803,76.862L73.803,76.862C73.578,76.637 73.275,76.506 72.957,76.497C72.638,76.487 72.328,76.6 72.09,76.811L72.09,76.811L64.75,83.337V85C64.75,86.243 65.757,87.25 67,87.25H74.69L79.44,82.499ZM74.975,88.75C74.992,88.751 75.008,88.751 75.025,88.75H87C89.071,88.75 90.75,87.071 90.75,85V84.006C90.75,84.003 90.75,83.999 90.75,83.995V69C90.75,66.929 89.071,65.25 87,65.25H67C64.929,65.25 63.25,66.929 63.25,69V82.997V83.004V85C63.25,87.071 64.929,88.75 67,88.75H74.975ZM89.25,84.352V85C89.25,86.243 88.243,87.25 87,87.25H76.811L83.239,80.822L83.24,80.821C83.46,80.601 83.755,80.47 84.066,80.456C84.378,80.441 84.683,80.544 84.923,80.743L84.924,80.743L89.25,84.352ZM80.75,72C80.75,71.31 81.31,70.75 82,70.75C82.69,70.75 83.25,71.31 83.25,72C83.25,72.69 82.69,73.25 82,73.25C81.31,73.25 80.75,72.69 80.75,72ZM82,69.25C80.481,69.25 79.25,70.481 79.25,72C79.25,73.519 80.481,74.75 82,74.75C83.519,74.75 84.75,73.519 84.75,72C84.75,70.481 83.519,69.25 82,69.25Z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle"
xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/block_background" />
<corners android:radius="12dp" />
</shape>
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@color/secondary_screen_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/nftDetailsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false" />
<ProgressBar
android:id="@+id/nftDetailsProgress"
style="@style/Widget.Nova.ProgressBar.Indeterminate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp" />
<ImageView
android:id="@+id/nftDetailsMedia"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
tools:src="@tools:sample/backgrounds/scenic" />
<TextView
android:id="@+id/nftDetailsTitle"
style="@style/TextAppearance.NovaFoundation.Bold.Title1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="16dp"
android:textColor="@color/text_primary"
tools:text="Honeybird" />
<io.novafoundation.nova.feature_nft_impl.presentation.nft.common.NftIssuanceView
android:id="@+id/nftDetailsIssuance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="16dp"
android:layout_marginTop="8dp"
tools:text="#11 Edition of 9978" />
<TextView
android:id="@+id/nftDetailsDescription"
style="@style/TextAppearance.NovaFoundation.Regular.SubHeadline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="24dp"
android:textColor="@color/text_secondary"
tools:text="Wayne: Hello sir, what's your name and what brings you to Pastel Beach Mall?" />
<io.novafoundation.nova.feature_wallet_api.presentation.view.PriceSectionView
android:id="@+id/nftDetailsPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp" />
<io.novafoundation.nova.common.view.TableView
android:id="@+id/nftDetailsTable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="24dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/nftDetailsCollection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/nft_collection" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/nftDetailsOnwer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/nft_owned_by" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/nftDetailsCreator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/nft_created_by" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/nftDetailsChain"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/common_network" />
</io.novafoundation.nova.common.view.TableView>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/drawable_background_image"
android:orientation="vertical">
<LinearLayout
android:id="@+id/nftListToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/blur_navigation_background"
android:gravity="center_vertical">
<ImageView
android:id="@+id/nftListBack"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:src="@drawable/ic_arrow_back"
app:tint="@color/actions_color" />
<TextView
style="@style/TextAppearance.NovaFoundation.SemiBold.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:layout_weight="1"
android:gravity="center"
android:text="@string/nft_your_nfts"
android:textColor="@color/text_primary" />
<io.novafoundation.nova.common.view.CounterView
android:id="@+id/nftListCounter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
tools:text="6" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/nftListRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/nftListNfts"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="5dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:listitem="@layout/item_nft" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/itemNftContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="11dp"
android:layout_marginBottom="12dp"
app:shimmer_auto_start="false"
tools:background="@color/block_background">
<com.facebook.shimmer.ShimmerFrameLayout
android:id="@+id/itemNftShimmer"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<View
android:layout_width="match_parent"
android:layout_height="262dp"
android:background="@drawable/bg_shimmering" />
</com.facebook.shimmer.ShimmerFrameLayout>
<LinearLayout
android:id="@+id/itemNftContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/itemNftMedia"
android:layout_width="match_parent"
android:layout_height="154dp"
android:layout_marginStart="6dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="6dp"
android:adjustViewBounds="true"
android:maxHeight="154dp"
tools:src="@drawable/nft_media_progress" />
<TextView
android:id="@+id/itemNftTitle"
style="@style/TextAppearance.NovaFoundation.Regular.SubHeadline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:singleLine="true"
android:textColor="@color/text_primary"
tools:text="Honeybird" />
<io.novafoundation.nova.feature_nft_impl.presentation.nft.common.NftIssuanceView
android:id="@+id/itemNftIssuance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="12dp"
android:ellipsize="end"
android:singleLine="true"
tools:text="#11 Edition of 9978" />
<View
android:id="@+id/itemNftPriceDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="@color/divider" />
<LinearLayout
android:id="@+id/itemNftPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/itemNftPricePlaceholder"
style="@style/TextAppearance.NovaFoundation.SemiBold.Footnote"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:includeFontPadding="false"
android:text="@string/nft_price_not_listed"
android:textColor="@color/text_secondary"
android:visibility="gone"
/>
<TextView
android:id="@+id/itemNftPriceToken"
style="@style/TextAppearance.NovaFoundation.SemiBold.Footnote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:textColor="@color/text_primary"
tools:text="10 DOT" />
<TextView
android:id="@+id/itemNftPriceFiat"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="@color/text_secondary"
tools:text="($865.19)" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Widget.Nova.NftIssuance" parent="TextAppearance.NovaFoundation.SemiBold.Caps2">
<item name="android:paddingStart">6dp</item>
<item name="android:paddingTop">1.5dp</item>
<item name="android:paddingEnd">8dp</item>
<item name="android:paddingBottom">1.5dp</item>
<item name="android:textColor">@color/text_secondary</item>
</style>
</resources>
@@ -0,0 +1,17 @@
package io.novafoundation.nova
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.feature_nft_impl.data.network.distributed
import org.junit.Assert.assertEquals
import org.junit.Test
class FileStorageAdapterTest {
@Test
fun `should adapt ipfs to http`() {
runTest(
initial = "ipfs://ipfs/bafkreig7jn6iwz4fo3mwkl3ndljbrf7ot4mwyvpmzrj4vm2nvwzw6dysb4",
expected = "${FileStorage.IPFS.defaultHttpsGateway}bafkreig7jn6iwz4fo3mwkl3ndljbrf7ot4mwyvpmzrj4vm2nvwzw6dysb4"
)
}
@Test
fun `should fallback`() {
runTest(
initial = "bafkreig7jn6iwz4fo3mwkl3ndljbrf7ot4mwyvpmzrj4vm2nvwzw6dysb4",
expected = "${FileStorage.IPFS.defaultHttpsGateway}bafkreig7jn6iwz4fo3mwkl3ndljbrf7ot4mwyvpmzrj4vm2nvwzw6dysb4"
)
}
@Test
fun `should leave http and https as is`() {
runTest(
initial = "https://singular.rmrk.app/api/rmrk1/account/EkLXe943A4Ceu8rF4a2Wb8s5BHuTdTcEszJJCgsEPwAiGCi",
expected = "https://singular.rmrk.app/api/rmrk1/account/EkLXe943A4Ceu8rF4a2Wb8s5BHuTdTcEszJJCgsEPwAiGCi"
)
runTest(
initial = "http://singular.rmrk.app/api/rmrk1/account/EkLXe943A4Ceu8rF4a2Wb8s5BHuTdTcEszJJCgsEPwAiGCi",
expected = "http://singular.rmrk.app/api/rmrk1/account/EkLXe943A4Ceu8rF4a2Wb8s5BHuTdTcEszJJCgsEPwAiGCi"
)
}
private fun runTest(
initial: String,
expected: String,
) {
val actual = FileStorageAdapter.adaptToHttps(initial)
assertEquals(expected, actual)
}
}