Updated on 2026-08-14
This commit is contained in:
parent
ded9de148c
commit
377f2cd6fd
8 changed files with 406 additions and 1 deletions
|
|
@ -31,6 +31,14 @@ class ResponseCryptoCurrenciesFactory(
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createCurrencies(tokens: List<UserTokensResponse.Token>, scanResponse: ScanResponse): List<CryptoCurrency> {
|
||||||
|
return tokens
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { createCurrency(it, scanResponse) }
|
||||||
|
.distinctBy(CryptoCurrency::id)
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
fun createCurrency(responseToken: UserTokensResponse.Token, scanResponse: ScanResponse): CryptoCurrency? {
|
fun createCurrency(responseToken: UserTokensResponse.Token, scanResponse: ScanResponse): CryptoCurrency? {
|
||||||
var blockchain = Blockchain.fromNetworkId(responseToken.networkId)
|
var blockchain = Blockchain.fromNetworkId(responseToken.networkId)
|
||||||
if (blockchain == null || blockchain == Blockchain.Unknown) {
|
if (blockchain == null || blockchain == Blockchain.Unknown) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.tangem.data.networks.multi
|
||||||
|
|
||||||
|
import arrow.core.Either
|
||||||
|
import arrow.core.raise.either
|
||||||
|
import arrow.core.raise.ensure
|
||||||
|
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
|
||||||
|
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [MultiNetworkStatusFetcher]
|
||||||
|
*
|
||||||
|
* @property singleNetworkStatusFetcher single network status fetcher
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
|
||||||
|
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
|
||||||
|
) : MultiNetworkStatusFetcher {
|
||||||
|
|
||||||
|
override suspend fun invoke(params: MultiNetworkStatusFetcher.Params): Either<Throwable, Unit> = either {
|
||||||
|
val result = coroutineScope {
|
||||||
|
params.networks
|
||||||
|
.map {
|
||||||
|
async {
|
||||||
|
singleNetworkStatusFetcher(
|
||||||
|
params = SingleNetworkStatusFetcher.Params(
|
||||||
|
userWalletId = params.userWalletId,
|
||||||
|
network = it,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
val failedResult = result.firstOrNull { it.isLeft() }
|
||||||
|
|
||||||
|
ensure(failedResult == null) {
|
||||||
|
IllegalStateException("Failed to fetch network statuses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package com.tangem.data.networks.single
|
||||||
|
|
||||||
|
import arrow.core.Either
|
||||||
|
import com.tangem.blockchain.common.Blockchain
|
||||||
|
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||||
|
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||||
|
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||||
|
import com.tangem.data.networks.store.NetworksStatusesStoreV2
|
||||||
|
import com.tangem.data.tokens.utils.CardCryptoCurrenciesFactory
|
||||||
|
import com.tangem.data.tokens.utils.NetworkStatusFactory
|
||||||
|
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||||
|
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
|
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||||
|
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||||
|
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||||
|
import com.tangem.domain.common.util.cardTypesResolver
|
||||||
|
import com.tangem.domain.demo.DemoConfig
|
||||||
|
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
|
||||||
|
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||||
|
import com.tangem.domain.tokens.model.Network
|
||||||
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
|
import com.tangem.domain.wallets.models.UserWallet
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [SingleNetworkStatusFetcher]
|
||||||
|
*
|
||||||
|
* @param excludedBlockchains excluded blockchains
|
||||||
|
* @property walletManagersFacade wallet managers facade
|
||||||
|
* @property networksStatusesStore networks statuses store
|
||||||
|
* @property userWalletsStore user wallets store
|
||||||
|
* @property appPreferencesStore app preferences store
|
||||||
|
* @property dispatchers dispatchers
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
|
||||||
|
excludedBlockchains: ExcludedBlockchains,
|
||||||
|
private val walletManagersFacade: WalletManagersFacade,
|
||||||
|
private val networksStatusesStore: NetworksStatusesStoreV2,
|
||||||
|
private val userWalletsStore: UserWalletsStore,
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
) : SingleNetworkStatusFetcher {
|
||||||
|
|
||||||
|
private val demoConfig = DemoConfig()
|
||||||
|
private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(demoConfig, excludedBlockchains)
|
||||||
|
private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains)
|
||||||
|
private val networkStatusFactory = NetworkStatusFactory()
|
||||||
|
|
||||||
|
override suspend fun invoke(params: SingleNetworkStatusFetcher.Params): Either<Throwable, Unit> = Either.catch {
|
||||||
|
networksStatusesStore.refresh(userWalletId = params.userWalletId, network = params.network)
|
||||||
|
|
||||||
|
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||||
|
val networkCurrencies = createCurrencies(userWallet = userWallet, network = params.network)
|
||||||
|
|
||||||
|
val result = withContext(dispatchers.io) {
|
||||||
|
walletManagersFacade.update(
|
||||||
|
userWalletId = params.userWalletId,
|
||||||
|
network = params.network,
|
||||||
|
extraTokens = networkCurrencies
|
||||||
|
.filterIsInstance<CryptoCurrency.Token>()
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = networkStatusFactory.createNetworkStatus(
|
||||||
|
network = params.network,
|
||||||
|
result = result,
|
||||||
|
currencies = networkCurrencies.toSet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
networksStatusesStore.storeActual(userWalletId = params.userWalletId, value = status)
|
||||||
|
}
|
||||||
|
.onLeft {
|
||||||
|
Timber.e("Failed to fetch network status for $params: $it")
|
||||||
|
networksStatusesStore.storeError(userWalletId = params.userWalletId, network = params.network)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createCurrencies(userWallet: UserWallet, network: Network): List<CryptoCurrency> {
|
||||||
|
val blockchain = Blockchain.fromNetworkId(networkId = network.backendId)
|
||||||
|
|
||||||
|
// multi-currency wallet
|
||||||
|
if (userWallet.isMultiCurrency) return getMultiWalletCurrencies(userWallet = userWallet, network = network)
|
||||||
|
|
||||||
|
// check if the blockchain of single-currency wallet is the same as network
|
||||||
|
val cardBlockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain()
|
||||||
|
if (cardBlockchain != blockchain) return emptyList()
|
||||||
|
|
||||||
|
// single-currency wallet with token (NODL)
|
||||||
|
if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
|
||||||
|
return cardCurrenciesFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet.scanResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// single-currency wallet
|
||||||
|
return cardCurrenciesFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet.scanResponse).let(::listOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getMultiWalletCurrencies(userWallet: UserWallet, network: Network): List<CryptoCurrency> {
|
||||||
|
val response = appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
|
||||||
|
key = PreferencesKeys.getUserTokensKey(userWallet.walletId.stringValue),
|
||||||
|
) ?: return emptyList()
|
||||||
|
|
||||||
|
return responseCurrenciesFactory.createCurrencies(
|
||||||
|
tokens = response.tokens.filter {
|
||||||
|
it.networkId == network.backendId && it.derivationPath == network.derivationPath.value
|
||||||
|
},
|
||||||
|
scanResponse = userWallet.scanResponse,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
package com.tangem.data.networks.multi
|
||||||
|
|
||||||
|
import arrow.core.left
|
||||||
|
import arrow.core.right
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||||
|
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
|
||||||
|
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
|
||||||
|
import com.tangem.domain.wallets.models.UserWalletId
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class DefaultMultiNetworkStatusFetcherTest {
|
||||||
|
|
||||||
|
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk()
|
||||||
|
|
||||||
|
private val fetcher = DefaultMultiNetworkStatusFetcher(singleNetworkStatusFetcher = singleNetworkStatusFetcher)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fetch networks statuses successfully`() = runTest {
|
||||||
|
val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
|
||||||
|
|
||||||
|
val ethParams = SingleNetworkStatusFetcher.Params(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
network = ethereumAndStellar.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val stellarParams = SingleNetworkStatusFetcher.Params(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
network = ethereumAndStellar.last(),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery { singleNetworkStatusFetcher(ethParams) } returns Unit.right()
|
||||||
|
coEvery { singleNetworkStatusFetcher(stellarParams) } returns Unit.right()
|
||||||
|
|
||||||
|
val actual = fetcher(params)
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
singleNetworkStatusFetcher(ethParams)
|
||||||
|
singleNetworkStatusFetcher(stellarParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
Truth.assertThat(actual.isRight()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fetch networks statuses failure if one of them fails`() = runTest {
|
||||||
|
val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
|
||||||
|
|
||||||
|
val ethParams = SingleNetworkStatusFetcher.Params(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
network = ethereumAndStellar.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val stellarParams = SingleNetworkStatusFetcher.Params(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
network = ethereumAndStellar.last(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val ethException = IllegalStateException("eth")
|
||||||
|
coEvery { singleNetworkStatusFetcher(ethParams) } returns ethException.left()
|
||||||
|
coEvery { singleNetworkStatusFetcher(stellarParams) } returns Unit.right()
|
||||||
|
|
||||||
|
val actual = fetcher(params)
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
singleNetworkStatusFetcher(ethParams)
|
||||||
|
singleNetworkStatusFetcher(stellarParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
val expected = IllegalStateException("Failed to fetch network statuses")
|
||||||
|
|
||||||
|
Truth.assertThat(actual.isLeft()).isTrue()
|
||||||
|
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
|
||||||
|
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fetch networks statuses failure if all of them fails`() = runTest {
|
||||||
|
val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
|
||||||
|
|
||||||
|
val ethParams = SingleNetworkStatusFetcher.Params(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
network = ethereumAndStellar.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val stellarParams = SingleNetworkStatusFetcher.Params(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
network = ethereumAndStellar.last(),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery { singleNetworkStatusFetcher(ethParams) } returns IllegalStateException("eth").left()
|
||||||
|
coEvery { singleNetworkStatusFetcher(stellarParams) } returns IllegalStateException("stellar").left()
|
||||||
|
|
||||||
|
val actual = fetcher(params)
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
singleNetworkStatusFetcher(ethParams)
|
||||||
|
singleNetworkStatusFetcher(stellarParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
val expected = IllegalStateException("Failed to fetch network statuses")
|
||||||
|
|
||||||
|
Truth.assertThat(actual.isLeft()).isTrue()
|
||||||
|
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
|
||||||
|
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val userWalletId = UserWalletId("011")
|
||||||
|
val ethereumAndStellar = MockCryptoCurrencyFactory().ethereumAndStellar.map { it.network }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package com.tangem.data.networks.single
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||||
|
import com.tangem.data.networks.store.NetworksStatusesStoreV2
|
||||||
|
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||||
|
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
|
||||||
|
import com.tangem.domain.tokens.model.NetworkStatus
|
||||||
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
|
import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
|
||||||
|
import com.tangem.domain.wallets.models.UserWalletId
|
||||||
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
|
import io.mockk.Ordering
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class DefaultSingleNetworkStatusFetcherTest {
|
||||||
|
|
||||||
|
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
|
||||||
|
private val networksStatusesStore: NetworksStatusesStoreV2 = mockk(relaxed = true)
|
||||||
|
private val userWalletsStore: UserWalletsStore = mockk(relaxed = true)
|
||||||
|
|
||||||
|
private val fetcher = DefaultSingleNetworkStatusFetcher(
|
||||||
|
excludedBlockchains = mockk(relaxed = true),
|
||||||
|
walletManagersFacade = walletManagersFacade,
|
||||||
|
networksStatusesStore = networksStatusesStore,
|
||||||
|
userWalletsStore = userWalletsStore,
|
||||||
|
appPreferencesStore = mockk(relaxed = true),
|
||||||
|
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fetch network status successfully`() = runTest {
|
||||||
|
val params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network)
|
||||||
|
|
||||||
|
val result = UpdateWalletManagerResult.MissedDerivation
|
||||||
|
coEvery { walletManagersFacade.update(userWalletId, network, emptySet()) } returns result
|
||||||
|
|
||||||
|
val actual = fetcher(params)
|
||||||
|
|
||||||
|
coVerify(ordering = Ordering.SEQUENCE) {
|
||||||
|
networksStatusesStore.refresh(userWalletId = userWalletId, network = network)
|
||||||
|
userWalletsStore.getSyncStrict(key = userWalletId)
|
||||||
|
walletManagersFacade.update(userWalletId, network, emptySet())
|
||||||
|
networksStatusesStore.storeActual(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
value = NetworkStatus(network, NetworkStatus.MissedDerivation),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Truth.assertThat(actual.isRight()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fetch network status failure`() = runTest {
|
||||||
|
val params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network)
|
||||||
|
|
||||||
|
val exception = IllegalStateException()
|
||||||
|
coEvery { userWalletsStore.getSyncStrict(key = userWalletId) } throws exception
|
||||||
|
|
||||||
|
val actual = fetcher(params)
|
||||||
|
|
||||||
|
coVerify(ordering = Ordering.SEQUENCE) {
|
||||||
|
networksStatusesStore.refresh(userWalletId = userWalletId, network = network)
|
||||||
|
userWalletsStore.getSyncStrict(key = userWalletId)
|
||||||
|
networksStatusesStore.storeError(userWalletId = userWalletId, network = network)
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(inverse = true) {
|
||||||
|
walletManagersFacade.update(userWalletId = any(), network = any(), extraTokens = any())
|
||||||
|
networksStatusesStore.storeActual(userWalletId = any(), value = any())
|
||||||
|
}
|
||||||
|
|
||||||
|
Truth.assertThat(actual.isLeft()).isTrue()
|
||||||
|
Truth.assertThat(actual.leftOrNull()).isEqualTo(exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val userWalletId = UserWalletId("011")
|
||||||
|
val network = MockCryptoCurrencyFactory().ethereum.network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ import com.tangem.domain.walletmanager.model.CryptoCurrencyTransaction
|
||||||
import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
|
import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
internal class NetworkStatusFactory {
|
class NetworkStatusFactory {
|
||||||
|
|
||||||
fun createNetworkStatus(
|
fun createNetworkStatus(
|
||||||
network: Network,
|
network: Network,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.tangem.domain.networks.multi
|
||||||
|
|
||||||
|
import com.tangem.domain.core.flow.FlowFetcher
|
||||||
|
import com.tangem.domain.tokens.model.Network
|
||||||
|
import com.tangem.domain.wallets.models.UserWalletId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetcher of network status [Network] for wallet with [UserWalletId]
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
interface MultiNetworkStatusFetcher : FlowFetcher<MultiNetworkStatusFetcher.Params> {
|
||||||
|
|
||||||
|
data class Params(val userWalletId: UserWalletId, val networks: Set<Network>)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.tangem.domain.networks.single
|
||||||
|
|
||||||
|
import com.tangem.domain.core.flow.FlowFetcher
|
||||||
|
import com.tangem.domain.tokens.model.Network
|
||||||
|
import com.tangem.domain.wallets.models.UserWalletId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetcher of network status [Network] for wallet with [UserWalletId]
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
interface SingleNetworkStatusFetcher : FlowFetcher<SingleNetworkStatusFetcher.Params> {
|
||||||
|
|
||||||
|
data class Params(val userWalletId: UserWalletId, val network: Network)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue