Initial commit: Pezkuwi Wallet Android

Complete rebrand of Nova Wallet for Pezkuwichain ecosystem.

## Features
- Full Pezkuwichain support (HEZ & PEZ tokens)
- Polkadot ecosystem compatibility
- Staking, Governance, DeFi, NFTs
- XCM cross-chain transfers
- Hardware wallet support (Ledger, Polkadot Vault)
- WalletConnect v2
- Push notifications

## Languages
- English, Turkish, Kurmanci (Kurdish), Spanish, French, German, Russian, Japanese, Chinese, Korean, Portuguese, Vietnamese

Based on Nova Wallet by Novasama Technologies GmbH
© Dijital Kurdistan Tech Institute 2026
This commit is contained in:
2026-01-23 01:31:12 +03:00
commit 31c8c5995f
7621 changed files with 425838 additions and 0 deletions
@@ -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)
}
}